Compare commits

..

62 Commits

Author SHA1 Message Date
diana.dolgolyova 1571b63ec3 feat: separate /team and /schedule pages with Pinterest-style team grid
- Add /team page with masonry grid, style filters, search, and trainer profiles
- Add /schedule page as dedicated full-page schedule viewer
- Landing page: replace team section with auto-scrolling photo marquee (TeamPreview)
  - Clicking a trainer opens modal overlay with profile (no page navigation)
  - "Познакомиться с командой" links to full /team gallery
- Landing page: replace schedule section with compact CTA linking to /schedule
- Header: support mixed route links (/team) and hash links (#about)
  - Sub-pages show all nav links, hash links prefixed with /
  - Sub-pages always use scrolled header style (readable on light theme)
- Remove unused content.ts and seed.ts (DB is primary data source)
- Add marquee and grid card entrance animations
2026-04-13 22:01:38 +03:00
diana.dolgolyova 8c84da279e fix: widen classes container, wrap schedule tags on mobile
Classes section: replace section-container with wider max-w-[96rem] to reduce side gaps.
Schedule DayCard: allow tags to flex-wrap so they don't clip on narrow screens.
2026-04-12 21:01:10 +03:00
diana.dolgolyova 03d3cad0a7 fix: remove section-glow radial gradient — visible as artifact between sections 2026-04-12 20:27:59 +03:00
diana.dolgolyova 89f132634d feat: gold headings, glass header, fixed glow positioning
- SectionHeading: gold text instead of gradient-text
- Hero h1: gold, subtitle: white/80
- Header: pure backdrop-blur-xl glass, no bg color
- Section glow: fixed top:0, original size 600x400
- Section padding: py-20 sm:py-28
- Markup: headings/bold text-neutral-900 dark:text-white
2026-04-12 20:26:46 +03:00
diana.dolgolyova b7eacce479 feat: widen content container max-w-6xl → max-w-7xl (+128px) 2026-04-12 16:08:14 +03:00
diana.dolgolyova a832af9344 revert: remove decorative side lines — didn't look right 2026-04-12 16:03:08 +03:00
diana.dolgolyova b738976111 feat: flowing gold side lines + pure glass header
- DecorativeLines: SVG flowing bezier curves on both sides, gold
  gradient with fade at top/bottom, desktop only (xl+), starts
  after hero section
- Header: pure backdrop-blur-xl glass with no bg color overlay,
  subtle border-white/8% bottom line
- Removed old straight CSS lines from body
2026-04-12 15:58:58 +03:00
diana.dolgolyova a00fdaa760 feat: decorative gold side lines + frosted glass header
- Gold vertical lines on both sides (desktop 1280px+), fixed position,
  gradient fade at top/bottom, positioned at 3vw from edges
- Header light mode: frosted glass (bg-white/60 backdrop-blur-xl)
  with subtle bottom border, matching dark mode aesthetic
2026-04-12 15:55:26 +03:00
diana.dolgolyova 9f86bcbce9 fix: markup headings and bold text visible in light mode 2026-04-12 15:52:19 +03:00
diana.dolgolyova 28afcc18bc fix: hero always dark bg — no light/dark split for video section 2026-04-12 15:49:23 +03:00
diana.dolgolyova b9510213d7 fix: headings white instead of gold gradient, gold as accent only
- SectionHeading: text-neutral-900/dark:text-white instead of
  gradient-text — cleaner, more premium look
- Hero: white h1 instead of gradient-text, gold reserved for
  subtitle and CTA button only
- Gold accent now used sparingly: underlines, badges, buttons, hover
  states — not headings
2026-04-12 15:46:27 +03:00
diana.dolgolyova bac46aeb34 feat: dark luxury gold — spacing, typography, frosted glass, glow
Design direction: "Dark Luxury Gold" — more breathing room, bolder
headings, frosted glass cards, and richer gold accents.

- Section padding: py-20→py-24, sm:py-32→sm:py-36, px-6→sm:px-10
- SectionHeading: text-6xl→7xl on lg, tracking-wide→wider, longer
  gold gradient underline with via-gold/40
- Section glow: larger radius (800px), positioned higher (-40px),
  elliptical shape for dark mode
- Glass card: backdrop-blur-md→lg in dark, border white/8%, hover
  with subtle gold glow shadow
- Section divider: wider gradient spread with 5% transparent edges
- About/FAQ/Pricing/DayCard: frosted glass in dark mode with
  backdrop-blur-md, subtle gold glow on hover
2026-04-12 15:34:01 +03:00
diana.dolgolyova 3621503470 feat: add reusable UI primitives for admin and public site
Admin (ui.tsx): AdminInput, AdminSelect, AdminTextarea, AdminButton,
AdminModal + adminStyles token object for consistent styling.

Public (ModalBase.tsx): Shared modal with overlay, backdrop blur, focus
trap, ESC handling, and portal rendering.

Public (TabButton.tsx): Unified active/inactive tab with gold styling.
2026-04-12 15:27:40 +03:00
diana.dolgolyova a080ef5a8e fix: remaining admin & layout light theme polish
- Admin forms, dialogs, and page editors: light-mode borders, text contrast
- YandexMap: theme-aware map styles
- Layout: theme init script adjustments
2026-04-10 21:36:33 +03:00
diana.dolgolyova 97663c514e fix: admin bookings light theme — readable names, badges, and actions
- Name text: text-white → text-neutral-900 dark:text-white
- Status badges: -400 colors → -600 for light, dark: keeps -400
- Contact links: same -600/-400 split
- BookingCard borders/bg: stronger opacity for light mode
- Action buttons: dark:text for light contrast
- Group counters: amber-700/blue-600/emerald-600 for light
2026-04-10 21:33:22 +03:00
diana.dolgolyova 0e626451e7 feat: comprehensive light theme support across entire site
- CSS foundation: theme-aware scrollbars, section glows, glass cards with
  gold shadows, stronger animated borders and glow effects for light mode
- Hero: consistent dark-video treatment for both themes, brighter gold
  gradient text, glowing CTA button
- Gradient text: auto-switch to warm gold tones on light backgrounds via
  html:not(.dark) selector
- Team profile: inverted ambient photo bg with white overlay for light,
  dark text/borders, gold-dark labels for contrast
- All sections: text-neutral-500→600 upgrades for WCAG AA contrast,
  gold shadow accents on cards (About, Pricing, FAQ, DayCard, News)
- Admin: replaced hardcoded #c9a96e with theme tokens, fixed select
  options, array editor borders, booking badges contrast
- Header: white text on transparent hero, dark text after scroll
- UI components: BackToTop, FloatingHearts, ShowcaseLayout tabs,
  SignupModal, NewsModal, GroupCard adapted for light backgrounds
- Updated CLAUDE.md to reflect dual theme support
2026-04-10 21:30:56 +03:00
diana.dolgolyova a587736dd3 feat: mobile UX, admin polish, rate limiting, and media assets
- Mobile responsiveness improvements across admin and public sections
- Admin: bookings modal, open-day page, team page, layout polish
- Added rate limiting, CSRF hardening, auth-edge improvements
- Scroll reveal, floating contact, back-to-top, Yandex map fixes
- Schedule filters refactor, team profile/info component updates
- New useTrainerPhotos hook
- Added class, team, master-class, and news images
2026-04-10 18:42:54 +03:00
diana.dolgolyova bbe485d8fc feat: add author credit to footer 2026-04-05 19:28:27 +03:00
diana.dolgolyova 16ac56f62e feat: mobile UX improvements across admin and public site
- Team carousel: simple swipe on mobile instead of drag
- Schedule: filter button inline with hall tabs, larger on mobile
- Schedule filters: fix nested button hydration error
- Admin bookings: select dropdown on mobile, filter highlight on dashboard cards
- Admin bookings: searchable dropdowns in add booking modal with class selector
- Admin bookings: waiting list divider inside groups
- Admin bookings: new bookings appear without page reload
- Admin open-day: action buttons visible on mobile, confirm dialog, click-outside to close edit
- API: pass groupInfo on group booking creation
- SignupModal: Instagram link on success popup
2026-04-03 17:06:55 +03:00
diana.dolgolyova fa26092ea4 fix: mobile horizontal overflow and hero responsiveness
- Remove scrollbar-gutter: stable, add overflow-x: hidden on html
- Cap section-glow pseudo-element width to viewport on mobile
- Scale down hero logo, text, spacing, and button for small screens (iPhone SE)
2026-04-03 00:14:42 +03:00
diana.dolgolyova bf9b1876b5 feat: hero UI polish — vignette, larger text, bolder button, spacing
- Vignette overlay: radial gradient darkens edges, guides eye to center
- Video overlay reduced 50% → 40% for brighter backgrounds
- Subtitle: larger (xl/2xl), brighter (gold/80), wider max-w-xl
- Button: larger padding, pulse glow on hover, stronger shadow
- Spacing: more room between logo, title, subtitle, and button
2026-04-02 15:43:56 +03:00
diana.dolgolyova 3c0be33b1c feat: transparent glass hero button, gold text
Hero CTA button: transparent gold/15 background with backdrop blur,
gold border, gold text. Frosted glass effect over video.
2026-04-02 15:41:51 +03:00
diana.dolgolyova 06f7dcf570 fix: admin sticky header, pricing rules textarea, hero 3D text shadow
- Admin mobile header: sticky top so it stays visible on scroll
- Pricing rules: auto-expanding textarea instead of single-line input
- Hero text: layered gold+shadow for 3D depth effect
2026-04-02 15:35:48 +03:00
diana.dolgolyova 2c6bee9eb1 feat: MC detail popup, image crop editor, empty dates support
Master Classes:
- Detail popup on card click with description, all dates, location+address
- Card shows only first date (or "Скоро" if no dates)
- Trainer name clickable to open bio
- Text backdrop panel on cards for readability
- No photo overlay darkening
- Fix crash when MC has no/empty slots
- Price "BYN" no longer duplicated
- Admin: ImageCropField replaces PhotoPreview (with focal/zoom)
- Admin: RichTextarea for description
- Admin: photo+fields side-by-side layout, fixed photo width

Pricing:
- Added rentalSubtitle field for rental tab info
2026-04-02 15:00:44 +03:00
diana.dolgolyova 2d13b82507 fix: dashboard card order, nav labels from DB, pricing rental info
- Dashboard cards reordered to match sidebar nav sequence
- Added missing "Формы записи" card to dashboard
- Nav menu reads section titles from DOM headings (not hardcoded)
- Pricing: moved subtitle inside Абонементы tab, renamed to "Доп. информация"
- Pricing: added "Доп. информация" field to Аренда tab
2026-03-31 00:17:26 +03:00
diana.dolgolyova ae30be8f9d fix: schedule status labels, Open Day halls, unsaved data guards
Schedule:
- Status badges use admin config labels (not hardcoded text) everywhere
- DayCard: level badge moved next to status badge
- Single location: hide "Все студии" tab, auto-select the only hall
- Group view: hide per-card address when all share same location
- Filter tooltip z-index fixed (above dropdowns)
- Trainer bio: status labels from config, not raw keys

Open Day:
- Hall name + address shown in schedule grid headers
- Only one class card editable at a time (edit/create mutually exclusive)
- Bigger action buttons (cancel/delete) on class cards
- Create as empty draft (not pre-filled with published status)
- Fix discount threshold input (allow delete to empty)
- Skip auto-save during partial date input

Admin:
- SectionEditor: unsaved data guard (force-save before navigation)
- Open Day + Team: same navigation guards
- Contact: removed working hours field
- TimeRangeField: allow end time hour changes
- Schedule cards: visible borders, 90min default duration
- Trainer bio: RichTextarea for description
- Open Day: RichTextarea for description
2026-03-30 22:57:36 +03:00
diana.dolgolyova 06be6b48ce feat: contact page improvements, Yandex map from addresses
- Instagram field: @username input with API validation (like team page)
- Phone validation: blocks auto-save when incomplete, shows warning
- SectionEditor: validate prop to conditionally block saves
- Yandex Map: auto-generated from addresses via Nominatim geocoding,
  dark theme, no API key needed
- Schedule: address hint linking to Contacts
- Renamed "Всплывающие окна" → "Формы записи", moved after Записи
2026-03-30 16:59:24 +03:00
diana.dolgolyova 22bd117dae feat: rich text editor, image crop component, empty DB resilience
- RichTextarea with toolbar (Bold, Italic, List, Heading) + Ctrl+B/I
  hotkeys (layout-independent), active state highlighting, preview mode
- Shared ImageCropField component (replaces duplicate in news/classes)
  with drag-to-reposition, Ctrl+scroll zoom, compact layout
- SectionEditor defaultData prop — all admin pages handle empty DB
- Team: section title editable, toast notifications, unsaved data warning
  on navigation (back button, sidebar links, browser close)
- Carousel: continuous card wrapping during drag, edge fade for small teams
- Markup renderer: **bold**, *italic*, ## headings, 🤍 bullet points
- Empty DB guards on all public site sections
- Fix: upload error handling, contact phone field, "team" section key
2026-03-30 00:40:08 +03:00
diana.dolgolyova e56a6a1608 fix: remove fallback content, fix video upload and positioning
- Remove hardcoded fallback data — DB is sole content source
- Sections render conditionally when data exists
- Hero video slots save after each upload (not only when all 3 filled)
- Video positions preserved (left/center/right) with empty string slots
- Client-side 10MB hard limit on video uploads with clear error
- Server-side upload error handling for body size limits
- Guard Team section against empty members array
- Clean up old uploaded images and videos
2026-03-29 22:17:11 +03:00
diana.dolgolyova 77ad2a6b68 fix: comprehensive UI/UX accessibility and usability improvements
Public site: skip-to-content link, mobile menu focus trap + Escape key,
aria-current on nav, keyboard navigation for carousels/tabs/articles,
ARIA roles (tablist/tab/tabpanel, combobox/listbox, region, dialog),
form labels + aria-describedby, 44px touch targets, semantic HTML
(<time>, <del>), prefers-reduced-motion on Hero scroll hijack,
mobile schedule filters, URL hash sync on scroll for correct refresh.

Admin panel: password toggle aria-label, toast aria-live regions,
SelectField keyboard navigation (Arrow/Enter/Escape), aria-invalid
on validation errors, sidebar hamburger aria-label/expanded,
nav aria-label, ArrayEditor aria-expanded on collapsible items.
2026-03-29 20:42:14 +03:00
diana.dolgolyova 024424c578 feat: unified badges, filter UI overhaul, trainer bio link
- ScheduleBadge: shared gold badge component for all status/level tags
- Replace hardcoded emerald/sky/rose badge colors with unified gold style
- DayCard, GroupCard, MobileSchedule all use ScheduleBadge
- Location tag moved before status badges, gold styling
- GroupCard: flex-col with mt-auto button alignment
- TeamProfile: pass hasSlots/status to GroupCard

Filter modal redesign:
- Status: horizontal Airbnb-style toggle cards with ? info tips
- Experience: vertical radio list with gold dots
- When: days + time inputs on single row
- Click-to-toggle InfoTip replacing hover tooltips
- Gold speech bubble tooltip design

Schedule cards:
- Trainer name click opens bio instead of filtering
- DayCard location header: gold background, no duplicate address
2026-03-29 17:04:40 +03:00
diana.dolgolyova bdeedcfcc8 fix: schedule status system — auto-key, config order, label lookup
- Auto-generate status key from label (admin doesn't need to set keys)
- Remove visible key field from status config editor
- Order statuses/levels in filters by config order (matches admin panel)
- Shared findStatusConfig() for robust label lookup (by key, label, or derived key)
- Custom status badges in DayCard, GroupCard, MobileSchedule
- Simplified filter logic with clsStatus helper
- Removed dead code: TIME_PRESETS, StatusFilter type
- SelectField: blur input after selection to prevent re-open
2026-03-28 00:33:55 +03:00
diana.dolgolyova b322c969f2 fix: schedule filters — restore status card design, responsive layout, cleanup
- Status: card layout on desktop, compact pills on mobile
- Experience: compact pills with ? hover tooltips (unchanged)
- Remove time presets (Morning/Day/Evening), keep only from-to inputs
- Combine Days + Time into single "Когда" section
- Fix tooltip overflow causing scrollbars (max-w + right-aligned)
- Tighten modal spacing (p-6→px-6 py-4, space-y-7→space-y-5)
- Clean unused imports (pillActive, pillInactive, User, Clock, etc.)
2026-03-27 23:54:16 +03:00
diana.dolgolyova a69c08482f feat: schedule filters overhaul, local fonts, configurable statuses/levels
Schedule filters:
- Airbnb-style filter modal with sections: directions, trainer, status, level, days, time
- Multi-select trainer filter with search input
- Custom time range (from-to) with preset shortcuts
- Gold tag design for class types, statuses, and levels
- Hover tooltips on level/status options with descriptions from config
- Filter icon button inline with view toggle (По дням / По группам)

Admin schedule:
- Configurable experience levels and statuses (add/edit/reorder/delete)
- New scheduleConfig DB section with auto-save
- Status/level dropdowns in class editor read from config
- Status select built dynamically from config
- New status field on ScheduleClass for custom statuses

Other:
- Local fonts (Inter + Oswald) bundled in public/fonts — no Google Fonts dependency
- SelectField combobox: search in main input field, no separate search inside dropdown
- Fix carousel trainer label flash on drag release
2026-03-27 19:13:43 +03:00
diana.dolgolyova d5541a8bc9 fix: prevent trainer label flash on carousel drag release 2026-03-27 14:39:44 +03:00
diana.dolgolyova 035f68776a feat: shared GroupCard component, admin status select, schedule level filter
- Extract shared GroupCard component used by both Schedule GroupView and TeamProfile
- Admin schedule: replace hasSlots/recruiting toggles with single Status select
- User schedule: add level filter pills (Начинающий/Без опыта, Продвинутый)
- Consistent group card styling across schedule and trainer bio views
2026-03-26 23:38:51 +03:00
diana.dolgolyova c4c3a7ab0d fix: schedule grid — clean time labels and corner cell background 2026-03-26 19:56:16 +03:00
diana.dolgolyova 76307e298b refactor: comprehensive frontend review — consistency, a11y, code quality
- Replace event dispatchers with BookingContext (Hero, Header, FloatingContact)
- Add focus trap hook for modals (SignupModal, NewsModal)
- Extract shared components: CollapsibleSection, ConfirmDialog, PriceField, AdminSkeleton
- Add delete confirmation dialog to ArrayEditor
- Replace hardcoded colors (#050505, #0a0a0a, #c9a96e, #2ecc71) with theme tokens
- Add CSS variables --color-surface-deep/dark for consistent dark surfaces
- Improve contrast: muted text neutral-500 → neutral-400 in dark mode
- Fix modal z-index hierarchy (modals z-60, header z-50, floats z-40)
- Consolidate duplicate formatDate → shared formatting.ts
- Add useMemo to TeamProfile groupMap computation
- Fix typography: responsive price text in Pricing section
- Add ARIA labels/expanded to FAQ, OpenDay, ArrayEditor grip handles
- Hide number input spinners globally
- Reorder admin sidebar: Dashboard → SEO → Bookings → site section order
- Use shared PriceField in Open Day editor
- Fix schedule grid first time slot (09:00) clipped by container
- Fix pre-existing type errors (bookings, hero, db interfaces)
2026-03-26 19:45:37 +03:00
diana.dolgolyova ec08f8e8d5 feat: OpenDay trainer photos + click-to-bio; remove filter text highlights 2026-03-26 13:33:00 +03:00
diana.dolgolyova a769ea844d fix: prevent layout jump on modal open — use scrollbar-gutter: stable 2026-03-26 13:24:37 +03:00
diana.dolgolyova 8088b99a43 feat: UI improvements — scrollbar, multi-filters, pricing fix, routing, modals
- Global page scrollbar styled with gold theme
- Schedule: multi-select for class types and status tags
- Pricing: fix tab switch blink (display toggle vs conditional render)
- OpenDay: trainer name more prominent, section divider added
- Team: browser back button closes trainer bio (history API)
- Modals: block scroll + compensate scrollbar width to prevent layout shift
- Header: remove booking button from desktop nav
2026-03-26 13:23:03 +03:00
diana.dolgolyova 228e547e10 feat: reorder sections — About → Classes → Team → OpenDay → Schedule → Pricing → MC 2026-03-26 12:35:10 +03:00
diana.dolgolyova c9303e5aad feat: news pagination — replace show more/collapse with page controls 2026-03-26 12:16:56 +03:00
diana.dolgolyova c9cfe63837 feat: schedule group view — trainer photos, today badge, click-to-bio 2026-03-26 12:06:46 +03:00
diana.dolgolyova f65a6ed811 feat: schedule — default to group view, make halls more visible 2026-03-26 11:44:53 +03:00
diana.dolgolyova 09b2f40090 feat: collapse/expand all — toggle icon in ArrayEditor + pricing sections
- ArrayEditor shows ChevronsUpDown toggle when collapsible with 2+ items
- Toggle appears even without label prop (fixes pricing missing icon)
- Pricing: centralized section state with toggle-all button for all 3 sections
2026-03-26 11:31:31 +03:00
diana.dolgolyova 4c8c6eb0d2 feat: news crop editor — natural drag, zoom slider, force-dynamic page
- Rewrite crop preview: drag moves image naturally (inverted focal)
- Add zoom slider (1x-3x) + mouse wheel zoom
- Apply imageZoom on user side (featured, compact, modal)
- Force-dynamic on main page to prevent stale cache
2026-03-26 11:11:39 +03:00
diana.dolgolyova 4b6443c867 feat: news improvements — crop preview, auto-date, validation, add-to-top
- Draggable focal point crop preview for news images (admin + user + modal)
- Auto-set date+time on creation, remove date picker
- Draft validation: title, text, image required — "Черновик" badge if missing
- Empty/draft news filtered from user side
- ArrayEditor: addPosition="top" option, fix new item expand + index shift
- News sorted newest first, "Показать ещё" pagination
2026-03-26 01:34:31 +03:00
diana.dolgolyova bc0f23df34 feat: contact admin — phone mask, Instagram validation, compact addresses, remove map URL 2026-03-26 01:14:26 +03:00
diana.dolgolyova ad1715acb8 feat: news admin — collapsible cards, photo preview; user side — sort by date, show more 2026-03-26 00:59:37 +03:00
diana.dolgolyova 30398d2aeb feat: FAQ collapsible + drag UX — gold styling, compact cards, drop highlight 2026-03-26 00:55:39 +03:00
diana.dolgolyova 95c33391e5 feat: pricing admin — collapsible sections, card collapse, remove contact toggle 2026-03-26 00:48:00 +03:00
diana.dolgolyova 64e923460f feat: MC admin — collapsible cards, filters, photo preview, validation, archive
- Collapsible cards with title + hall in label, archive badge
- Archived MCs sorted to bottom, dimmed with "Архив" badge
- Cards have hover + focus-within gold border highlight
- Date validation: error text for missing dates and invalid time ranges
- Search by title/trainer + filter by date (upcoming/past) and hall
- Photo preview with hover overlay (like trainer page)
- ArrayEditor: hiddenItems, getItemBadge props, focus-within styles
2026-03-26 00:43:09 +03:00
diana.dolgolyova 6c485872b0 feat: centralize popup texts in new admin tab /admin/popups
- New admin page for shared popup texts (success, waiting list, error, Instagram hint)
- Removed popup fields from MC and Open Day admin editors
- All SignupModals now read from centralized popups config
- Stored as "popups" section in DB with fallback defaults
2026-03-25 23:48:06 +03:00
diana.dolgolyova 983bf296fc fix: mobile menu — unified header background, remove booking button 2026-03-25 23:28:24 +03:00
diana.dolgolyova 4805c3b9ea feat: classes admin collapsible cards, icon curation, color fix + user-side polish
Admin classes:
- Collapsible cards in ArrayEditor (start collapsed, expand on click)
- Curated 29 dance-relevant icons shown by default, full search as fallback
- Color swatches: used colors dimmed instead of hidden (no layout shift)

User side:
- Classes: icon + name side by side on photo overlay
- ShowcaseLayout: fix image flash during transition (2-frame swap while hidden)
- Team bio: section headings gold, admin cards focus-within highlight
2026-03-25 23:26:15 +03:00
diana.dolgolyova 24d48a9409 feat: improve trainer bio UX — reorder sections, collapsible, scroll arrows, card hover
- Reorder: Groups → Description → Education → Achievements
- Education and Achievements are collapsible (collapsed by default)
- Section headings now gold instead of gray
- Scroll arrows (left/right) replace fade indicators, always visible
- Bigger cards (w-60), wider image thumbnails
- Card hover: gold border glow, brighter text, subtle shadow (user side)
- Admin cards: hover highlight + focus-within gold border for active editing
- Auto-add draft item on blur to prevent data loss
2026-03-25 23:16:23 +03:00
diana.dolgolyova e4cb38c409 refactor: simplify team bio — replace complex achievements with simple list, remove experience
- Replace VictoryItem (type/place/category/competition/city/date) with RichListItem (text + optional link/image)
- Remove VictoryItemListField, DateRangeField, CityField and related helpers
- Remove experience field from admin form and user profile (can be in bio text)
- Simplify TeamProfile: remove victory tabs, show achievements as RichCards
- Fix auto-save: snapshot comparison prevents false saves on focus/blur
- Add save on tab leave (visibilitychange) and page close (sendBeacon)
- Add save after image uploads (main photo, achievements, education)
- Auto-migrate old VictoryItem data to RichListItem format in DB parser
2026-03-25 22:53:30 +03:00
diana.dolgolyova 4d90785c5b fix: team editor layout — compact header with photo matching field height
- Photo 150px wide, stretches to match fields height (no empty space)
- Profile header: photo + name/role/instagram inline
- Full-width descriptions and biography below
- Remove extra closing div that caused TS error
2026-03-25 21:40:08 +03:00
diana.dolgolyova 36ea952e9b feat: admin UX — shared input classes, autocomplete role, auto-save team, video improvements
- Extract base input classes (baseInput, textAreaInput, smallInput, dashedInput) with gold hover
- Move AutocompleteMulti to shared FormField, support · separator
- Team editor: auto-save with toast, split name into first/last, autocomplete role from class styles
- Team photo: click-to-upload overlay, smaller 130px thumbnail
- Hero videos: play on hover, file size display, 8MB warning, total size performance table
- Remove ctaHref field from Hero admin (unused on frontend)
- Move Toast to shared _components for reuse across admin pages
2026-03-25 21:12:51 +03:00
diana.dolgolyova e64119aaa0 feat: hall info on booking cards, notes styling, sort + highlight fixes
- Add hall badge to Open Day and Classes booking cards
- Hall in group labels for Open Day and MC tabs
- Hall in reminders event labels
- Save confirmed_hall for group bookings (migration 17)
- Page-level hall filter for all tabs
- Waiting list uses total bookings (matches public display)
- Notes styling: subtle gray text, gold icon + white text on hover
- Cards: sort newly changed to top of status group
- Fix Open Day notes not showing (missing from row type + mapper)
2026-03-25 14:40:24 +03:00
diana.dolgolyova eb949f1a37 feat: booking UX improvements — waiting list, card focus, sort order
- Auto-note "Лист ожидания" for registrations when class is full
- Waiting list triggers on confirmed count (not total registrations)
- Card highlight + scroll after status change
- Hover effect on booking cards
- Freshly changed cards appear first in their status group
- Polling no longer remounts tabs (fixes page jump on approve)
- Fix MasterClassesData missing waitingListText type
- Add Turbopack troubleshooting docs to CLAUDE.md
2026-03-25 12:53:45 +03:00
184 changed files with 8235 additions and 4142 deletions
+14 -4
View File
@@ -7,7 +7,7 @@ Content language: Russian
## Tech Stack
- **Next.js 16** (App Router, TypeScript, Turbopack)
- **Tailwind CSS v4** (dark mode only, gold/black theme)
- **Tailwind CSS v4** (dual theme: dark default + light, gold accent)
- **lucide-react** for icons
- **better-sqlite3** for SQLite database
- **Fonts**: Inter (body) + Oswald (headings) via `next/font`
@@ -25,7 +25,7 @@ Content language: Russian
src/
├── app/
│ ├── layout.tsx # Root layout, fonts, metadata
│ ├── page.tsx # Landing: Hero → [OpenDay] → About → Team → Classes → MasterClasses → Schedule → Pricing → News → FAQ → Contact
│ ├── page.tsx # Landing: Hero → About → Classes → Team → [OpenDay] → Schedule → Pricing → MasterClasses → News → FAQ → Contact
│ ├── globals.css # Tailwind imports
│ ├── styles/
│ │ ├── theme.css # Theme variables, semantic classes
@@ -111,8 +111,9 @@ src/
## Brand / Styling
- **Accent**: gold (`#c9a96e` / `hsl(37, 42%, 61%)`)
- **Background**: `#050505` `#0a0a0a` (dark only)
- **Surface**: `#171717` dark cards
- **Dark theme** (default): background `#050505``#0a0a0a`, surface `#171717`, text `neutral-100`
- **Light theme**: background `white`/`neutral-50`, surface `white`, text `neutral-900`
- Theme toggle via `ThemeToggle` component, `.dark` class on `<html>`, stored in `localStorage`
- Logo: transparent PNG heart with gold glow, uses `unoptimized`
## Content Data
@@ -158,6 +159,15 @@ src/
- Migrations run automatically on server start via `runMigrations()` and are tracked in the `_migrations` table
- Use `CREATE TABLE IF NOT EXISTS` and column-existence checks (`PRAGMA table_info`) for safety
## Turbopack / Dev Server Troubleshooting
If the dev server hangs on "Compiling..." or shows a white page:
1. Kill all node processes: `taskkill /F /IM node.exe`
2. Remove stale lock: `rm -f .next/dev/lock`
3. Clear cache: `rm -rf .next node_modules/.cache`
4. Restart: `npm run dev`
- This often happens after shutting down the PC without stopping the server first
- Always stop the dev server (Ctrl+C) before shutting down
## Git
- Remote: Gitea at `git.dolgolyov-family.by`
- User: diana.dolgolyova
+20
View File
@@ -1,7 +1,27 @@
import type { NextConfig } from "next";
const securityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
...(process.env.NODE_ENV === "production"
? [{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }]
: []),
];
const nextConfig: NextConfig = {
serverExternalPackages: ["better-sqlite3"],
allowedDevOrigins: [
"black-heart.dolgolyov-family.by",
"192.168.2.56",
],
headers: async () => [
{
source: "/(.*)",
headers: securityHeaders,
},
],
};
export default nextConfig;
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -H 0.0.0.0",
"build": "next build",
"start": "next start",
"lint": "next lint",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 192" fill="currentColor" fill-rule="evenodd">
<path d="M118.02,188.43 C118.04,184.10 120.51,173.30 122.96,166.79 C126.11,158.42 133.55,147.62 144.55,135.42 C165.53,112.15 170.96,101.38 170.99,82.98 C171.00,72.35 168.51,62.96 162.47,50.94 C160.00,46.02 157.78,42.00 157.53,42.00 C157.29,42.00 158.24,45.04 159.64,48.75 C163.04,57.78 165.96,71.24 165.96,78.00 C165.97,85.89 163.51,95.22 159.27,103.40 C156.31,109.09 152.52,113.57 140.17,126.00 C131.69,134.53 123.42,143.44 121.79,145.81 C116.23,153.88 110.87,167.99 109.81,177.28 C109.51,179.99 109.37,179.90 105.02,174.28 C102.55,171.10 98.74,166.54 96.55,164.15 L92.56,159.80 L95.53,157.70 C100.61,154.12 105.90,148.12 108.76,142.70 C111.22,138.02 111.50,136.50 111.47,127.50 C111.45,118.43 111.02,116.15 106.86,103.00 C101.17,85.06 99.60,76.75 100.25,68.17 C100.75,61.51 104.60,48.83 107.29,45.00 C108.57,43.16 108.76,43.69 109.31,50.84 C110.42,65.22 115.99,75.08 126.37,81.04 C133.31,85.02 133.82,84.76 128.92,79.75 C124.20,74.93 119.44,65.68 118.16,58.84 C116.46,49.75 119.09,39.24 125.73,28.59 L128.79,23.69 L130.02,29.59 C130.69,32.84 133.23,39.92 135.65,45.33 C143.15,62.02 144.36,69.90 141.53,83.29 C140.04,90.28 134.00,104.04 127.66,114.86 C125.68,118.24 124.39,120.97 124.78,120.93 C125.18,120.90 128.41,117.53 131.97,113.46 C145.06,98.47 150.50,85.84 150.46,70.50 C150.43,59.80 149.70,57.36 141.46,40.79 C137.98,33.80 134.83,26.02 134.45,23.49 C133.85,19.50 134.07,18.56 136.10,16.40 C139.50,12.77 147.93,7.39 153.86,5.06 L159.00,3.03 L159.00,9.32 C159.00,18.37 162.11,24.01 172.06,33.00 C176.46,36.97 180.72,41.50 181.53,43.06 C183.67,47.20 183.39,56.94 180.95,63.13 C178.14,70.25 180.87,67.95 184.97,59.74 C190.78,48.12 188.70,39.73 177.15,28.15 C173.28,24.27 169.43,20.06 168.61,18.80 C166.51,15.58 164.79,7.21 165.54,3.83 C166.16,1.00 166.17,1.00 174.33,1.01 C178.82,1.02 184.15,1.29 186.17,1.63 C189.69,2.21 189.81,2.37 189.29,5.62 C188.42,10.94 190.83,20.71 195.10,29.20 C197.26,33.49 199.19,37.00 199.40,37.00 C199.62,37.00 198.67,33.51 197.31,29.25 C195.35,23.12 194.91,19.90 195.17,13.83 C195.36,9.61 195.85,5.81 196.28,5.39 C197.35,4.31 205.52,8.43 211.67,13.15 C218.45,18.35 221.00,23.72 220.99,32.74 C220.98,36.46 220.28,41.98 219.42,45.00 C218.57,48.02 217.63,51.40 217.34,52.50 C216.30,56.51 222.34,45.32 224.52,39.20 C225.76,35.73 227.01,32.66 227.29,32.38 C227.57,32.09 228.79,34.48 229.99,37.68 C238.21,59.57 232.78,83.80 215.76,101.15 C209.43,107.60 207.42,108.54 209.17,104.25 C210.91,100.00 210.37,85.54 208.08,74.64 C206.93,69.22 205.95,62.24 205.90,59.14 C205.80,53.85 205.74,53.72 204.93,57.00 C204.45,58.92 204.13,68.15 204.22,77.50 C204.37,93.11 204.20,94.91 202.15,99.45 C198.99,106.51 192.06,115.46 190.76,114.16 C188.49,111.89 189.93,84.88 192.72,77.19 C194.09,73.45 189.30,79.05 186.68,84.26 C182.02,93.55 180.69,101.03 181.41,113.97 L182.05,125.50 L169.94,135.00 C153.90,147.58 132.06,170.01 124.13,182.05 C118.83,190.09 118.00,190.96 118.02,188.43 Z M83.09,150.59 C78.00,145.44 77.78,144.99 78.44,141.34 C78.82,139.23 81.24,133.00 83.81,127.50 C88.47,117.57 88.50,117.43 88.49,107.00 C88.47,99.38 87.96,94.99 86.59,91.00 C84.28,84.21 77.06,69.61 76.36,70.30 C76.08,70.58 76.56,72.19 77.43,73.87 C79.91,78.65 82.99,92.88 82.99,99.57 C83.00,108.39 80.69,114.86 73.96,124.82 C70.68,129.67 68.00,134.17 68.00,134.82 C68.00,135.47 67.62,136.00 67.16,136.00 C66.07,136.00 57.00,128.93 57.00,128.07 C57.00,127.71 59.03,123.47 61.50,118.66 C66.60,108.75 67.24,103.18 64.48,92.59 C62.01,83.09 61.32,83.22 61.40,93.17 C61.45,100.19 61.02,103.39 59.69,106.10 C57.49,110.57 48.29,121.00 46.56,121.00 C44.40,121.00 39.79,109.24 39.24,102.34 C38.56,93.90 40.48,89.09 48.68,78.77 C62.32,61.60 65.53,49.22 60.98,31.41 C58.70,22.51 54.61,13.20 50.08,6.62 C47.54,2.92 47.30,2.10 48.60,1.60 C50.50,0.87 66.31,0.80 68.17,1.51 C69.18,1.90 69.42,4.19 69.15,10.95 C68.88,18.05 69.27,21.45 71.06,27.48 C72.30,31.66 73.77,35.36 74.33,35.70 C74.97,36.10 75.06,35.62 74.57,34.41 C74.15,33.36 73.57,28.88 73.27,24.45 C72.70,15.76 74.85,5.38 77.44,4.39 C79.59,3.56 92.09,10.37 97.73,15.45 C100.57,18.00 103.83,21.61 104.99,23.48 L107.09,26.88 L103.66,31.69 C94.93,43.97 91.54,55.17 91.64,71.50 C91.72,84.83 92.69,89.79 99.08,109.50 C105.86,130.41 104.79,139.90 94.39,151.01 C91.83,153.75 89.44,156.00 89.08,156.00 C88.72,156.00 86.03,153.57 83.09,150.59 Z M29.50,109.90 C26.20,107.67 21.64,104.05 19.38,101.84 L15.26,97.83 L17.24,92.67 C19.86,85.83 19.20,74.50 15.57,64.04 C12.16,54.24 10.98,53.26 12.75,61.71 C14.48,69.97 13.94,81.02 11.53,86.50 L9.77,90.50 L6.92,84.50 C2.82,75.84 1.00,67.75 1.00,58.18 C1.00,42.04 6.09,29.69 17.39,18.40 C23.48,12.31 32.07,6.09 30.82,8.67 C30.59,9.13 28.88,12.62 27.02,16.44 C21.43,27.90 22.74,38.16 31.08,48.28 C35.14,53.21 36.55,53.01 33.93,47.87 C31.25,42.61 30.40,34.47 31.90,28.31 C33.17,23.08 42.81,3.00 44.05,3.00 C44.47,3.00 46.18,6.79 47.86,11.42 C58.07,39.63 56.90,53.23 42.67,72.14 C31.96,86.38 29.60,96.21 33.92,108.52 C34.98,111.54 35.77,113.99 35.67,113.97 C35.58,113.96 32.80,112.12 29.50,109.90 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
"use client";
/** Reusable loading skeleton for admin pages */
export function AdminSkeleton({ rows = 3 }: { rows?: number }) {
return (
<div className="space-y-6 animate-pulse">
{/* Title skeleton */}
<div className="h-8 w-48 rounded-lg bg-neutral-800" />
{/* Content skeletons */}
{Array.from({ length: rows }, (_, i) => (
<div key={i} className="space-y-3">
<div className="h-4 w-24 rounded bg-neutral-800" />
<div className="h-10 w-full rounded-lg bg-neutral-800" />
</div>
))}
</div>
);
}
+241 -33
View File
@@ -2,7 +2,10 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { Plus, Trash2, GripVertical } from "lucide-react";
import { Plus, Trash2, GripVertical, ChevronDown, ChevronsUpDown } from "lucide-react";
import { ConfirmDialog } from "./ConfirmDialog";
let nextItemId = 1;
interface ArrayEditorProps<T> {
items: T[];
@@ -11,16 +14,33 @@ interface ArrayEditorProps<T> {
createItem: () => T;
label?: string;
addLabel?: string;
collapsible?: boolean;
getItemTitle?: (item: T, index: number) => string;
getItemBadge?: (item: T, index: number) => React.ReactNode;
hiddenItems?: Set<number>;
addPosition?: "top" | "bottom";
/** Render grip + content + delete on a single row (compact mode) */
inline?: boolean;
/** Hide the add button (when parent manages adding) */
hideAdd?: boolean;
}
export function ArrayEditor<T>({
items,
items = [] as unknown as T[],
onChange,
renderItem,
createItem,
label,
addLabel = "Добавить",
collapsible = false,
getItemTitle,
getItemBadge,
hiddenItems,
addPosition = "bottom",
inline = false,
hideAdd = false,
}: ArrayEditorProps<T>) {
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [insertAt, setInsertAt] = useState<number | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
@@ -29,6 +49,30 @@ export function ArrayEditor<T>({
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const [mounted, setMounted] = useState(false);
const [newItemIndex, setNewItemIndex] = useState<number | null>(null);
const [droppedIndex, setDroppedIndex] = useState<number | null>(null);
const [collapsed, setCollapsed] = useState<Set<number>>(() => collapsible ? new Set(items.map((_, i) => i)) : new Set());
// Stable keys for items — avoids index-as-key issues during reorder
const stableKeysRef = useRef<number[]>([]);
if (stableKeysRef.current.length < items.length) {
while (stableKeysRef.current.length < items.length) {
stableKeysRef.current.push(nextItemId++);
}
} else if (stableKeysRef.current.length > items.length) {
stableKeysRef.current = stableKeysRef.current.slice(0, items.length);
}
function getStableKey(index: number): number {
return stableKeysRef.current[index];
}
function toggleCollapse(index: number) {
setCollapsed(prev => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
}
useEffect(() => { setMounted(true); }, []);
@@ -47,6 +91,7 @@ export function ArrayEditor<T>({
}
function removeItem(index: number) {
stableKeysRef.current.splice(index, 1);
onChange(items.filter((_, i) => i !== index));
}
@@ -113,7 +158,14 @@ export function ArrayEditor<T>({
const updated = [...items];
const [moved] = updated.splice(capturedDrag, 1);
updated.splice(targetIndex, 0, moved);
// Sync stable keys
const keys = [...stableKeysRef.current];
const [movedKey] = keys.splice(capturedDrag, 1);
keys.splice(targetIndex, 0, movedKey);
stableKeysRef.current = keys;
onChange(updated);
setDroppedIndex(targetIndex);
setTimeout(() => setDroppedIndex(null), 1500);
}
}
});
@@ -130,32 +182,96 @@ export function ArrayEditor<T>({
function renderList() {
if (dragIndex === null || insertAt === null) {
return items.map((item, i) => (
return items.map((item, i) => {
const isCollapsed = collapsible && collapsed.has(i) && newItemIndex !== i;
const isHidden = hiddenItems?.has(i) ?? false;
const title = getItemTitle?.(item, i) || `#${i + 1}`;
return (
<div
key={i}
key={getStableKey(i)}
ref={(el) => { itemRefs.current[i] = el; }}
className={`rounded-lg border bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-all ${
newItemIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
}`}
className={`rounded-lg border bg-neutral-100/80 mb-3 hover:border-neutral-300 dark:hover:border-white/25 hover:bg-neutral-200/50 focus-within:border-gold/50 focus-within:bg-neutral-200 transition-all dark:bg-neutral-900/50 dark:hover:bg-neutral-800/50 dark:focus-within:bg-neutral-800 ${
newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-neutral-200 dark:border-white/10"
} ${isHidden ? "hidden" : ""}`}
>
<div className="flex items-start justify-between gap-2 mb-3">
{inline ? (
/* Inline: grip + content + delete on one row */
<div className="flex items-start gap-1.5 p-1.5">
<div
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-neutral-900 transition-colors select-none shrink-0 dark:hover:text-white"
onMouseDown={(e) => handleGripMouseDown(e, i)}
aria-label="Перетащить для сортировки"
role="button"
>
<GripVertical size={16} />
<GripVertical size={14} />
</div>
<div className="flex-1 min-w-0">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
<button
type="button"
onClick={() => removeItem(i)}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
onClick={() => setConfirmDelete(i)}
aria-label="Удалить элемент"
className="rounded p-1 mt-1.5 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
>
<Trash2 size={14} />
</button>
</div>
) : (
<>
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-neutral-900 transition-colors select-none dark:hover:text-white"
onMouseDown={(e) => handleGripMouseDown(e, i)}
aria-label="Перетащить для сортировки"
role="button"
>
<GripVertical size={16} />
</div>
{collapsible && (
<button
type="button"
onClick={() => toggleCollapse(i)}
aria-expanded={!isCollapsed}
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
>
<span className="text-sm font-medium text-neutral-700 truncate group-hover:text-neutral-900 transition-colors dark:text-neutral-300 dark:group-hover:text-white">{title}</span>
{getItemBadge?.(item, i)}
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
</button>
)}
</div>
<button
type="button"
onClick={() => setConfirmDelete(i)}
aria-label="Удалить элемент"
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
>
<Trash2 size={16} />
</button>
</div>
{collapsible ? (
<div
className="grid transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
>
<div className="overflow-hidden">
<div className="px-4 pb-4">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
));
</div>
</div>
) : (
<div className="px-4 pb-4">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
)}
</>
)}
</div>
);
});
}
const elements: React.ReactNode[] = [];
@@ -175,36 +291,76 @@ export function ArrayEditor<T>({
elements.push(
<div
key="placeholder"
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
style={{ height: dragSize.h }}
className="rounded-lg border-2 border-dashed border-gold/40 bg-gold/5 mb-3"
style={{ height: collapsible ? 48 : dragSize.h }}
/>
);
}
const item = items[i];
const isCollapsed = collapsible && collapsed.has(i);
const title = getItemTitle?.(item, i) || `#${i + 1}`;
elements.push(
<div
key={i}
key={getStableKey(i)}
ref={(el) => { itemRefs.current[i] = el; }}
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-colors"
className="rounded-lg border border-neutral-200 bg-neutral-100/80 mb-3 transition-colors dark:border-white/10 dark:bg-neutral-900/50"
>
<div className="flex items-start justify-between gap-2 mb-3">
{inline ? (
<div className="flex items-start gap-1.5 p-1.5">
<div className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-neutral-900 transition-colors select-none shrink-0 dark:hover:text-white"
onMouseDown={(e) => handleGripMouseDown(e, i)} aria-label="Перетащить для сортировки" role="button">
<GripVertical size={14} />
</div>
<div className="flex-1 min-w-0">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
<button type="button" onClick={() => removeItem(i)} aria-label="Удалить элемент"
className="rounded p-1 mt-1.5 text-neutral-500 hover:text-red-400 transition-colors shrink-0">
<Trash2 size={14} />
</button>
</div>
) : (
<>
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-neutral-900 transition-colors select-none dark:hover:text-white"
onMouseDown={(e) => handleGripMouseDown(e, i)}
aria-label="Перетащить для сортировки"
role="button"
>
<GripVertical size={16} />
</div>
<button
type="button"
onClick={() => removeItem(i)}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
>
{collapsible && (
<button type="button" onClick={() => toggleCollapse(i)} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group">
<span className="text-sm font-medium text-neutral-700 truncate group-hover:text-neutral-900 transition-colors dark:text-neutral-300 dark:group-hover:text-white">{title}</span>
{getItemBadge?.(item, i)}
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
</button>
)}
</div>
<button type="button" onClick={() => removeItem(i)} aria-label="Удалить элемент"
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0">
<Trash2 size={16} />
</button>
</div>
{collapsible ? (
<div className="grid transition-[grid-template-rows] duration-300 ease-out" style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}>
<div className="overflow-hidden">
<div className="px-4 pb-4">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
</div>
</div>
) : (
<div className="px-4 pb-4">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
)}
</>
)}
</div>
);
visualIndex++;
}
@@ -213,8 +369,8 @@ export function ArrayEditor<T>({
elements.push(
<div
key="placeholder"
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
style={{ height: dragSize.h }}
className="rounded-lg border-2 border-dashed border-gold/40 bg-gold/5 mb-3"
style={{ height: collapsible ? 48 : dragSize.h }}
/>
);
}
@@ -224,22 +380,66 @@ export function ArrayEditor<T>({
return (
<div>
{label && (
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3>
{(label || (collapsible && items.length > 1)) && (
<div className="flex items-center justify-between mb-3">
{label ? <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{label}</h3> : <div />}
{collapsible && items.length > 1 && (() => {
const allCollapsed = collapsed.size >= items.length;
return (
<button
type="button"
onClick={() => allCollapsed ? setCollapsed(new Set()) : setCollapsed(new Set(items.map((_, i) => i)))}
className="rounded p-1 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
title={allCollapsed ? "Развернуть все" : "Свернуть все"}
aria-label={allCollapsed ? "Развернуть все" : "Свернуть все"}
>
<ChevronsUpDown size={16} className={`transition-transform duration-200 ${allCollapsed ? "" : "rotate-90"}`} />
</button>
);
})()}
</div>
)}
{!hideAdd && addPosition === "top" && (
<button
type="button"
onClick={() => {
stableKeysRef.current = [nextItemId++, ...stableKeysRef.current];
onChange([createItem(), ...items]);
setNewItemIndex(0);
// Shift collapsed indices and ensure new item is expanded
setCollapsed(prev => {
const next = new Set<number>();
for (const idx of prev) next.add(idx + 1);
return next;
});
}}
className="mb-3 flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors dark:border-white/20 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/40"
>
<Plus size={16} />
{addLabel}
</button>
)}
<div>
{renderList()}
</div>
{!hideAdd && addPosition === "bottom" && (
<button
type="button"
onClick={() => { onChange([...items, createItem()]); setNewItemIndex(items.length); }}
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
onClick={() => {
stableKeysRef.current.push(nextItemId++);
onChange([...items, createItem()]);
setNewItemIndex(items.length);
setCollapsed(prev => { const next = new Set(prev); next.delete(items.length); return next; });
}}
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors dark:border-white/20 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/40"
>
<Plus size={16} />
{addLabel}
</button>
)}
{/* Floating clone following cursor */}
{mounted && dragIndex !== null &&
@@ -253,13 +453,21 @@ export function ArrayEditor<T>({
height: dragSize.h,
}}
>
<div className="h-full rounded-lg border-2 border-rose-500 bg-neutral-900/95 shadow-2xl shadow-rose-500/20 flex items-center gap-3 px-4">
<GripVertical size={16} className="text-rose-400 shrink-0" />
<span className="text-sm text-neutral-300">Перемещение элемента...</span>
<div className="h-full rounded-lg border-2 border-gold/60 bg-white/95 shadow-2xl shadow-gold/20 flex items-center gap-3 px-4 dark:bg-neutral-900/95">
<GripVertical size={16} className="text-gold shrink-0" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{collapsible && dragIndex !== null ? (getItemTitle?.(items[dragIndex], dragIndex) || "Перемещение...") : "Перемещение элемента..."}</span>
</div>
</div>,
document.body
)}
<ConfirmDialog
open={confirmDelete !== null}
title="Удалить элемент?"
message="Это действие нельзя отменить."
onConfirm={() => { if (confirmDelete !== null) removeItem(confirmDelete); setConfirmDelete(null); }}
onCancel={() => setConfirmDelete(null)}
/>
</div>
);
}
@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
interface CollapsibleSectionProps {
title: string;
count?: number;
defaultOpen?: boolean;
isOpen?: boolean;
onToggle?: () => void;
children: React.ReactNode;
}
/**
* Shared collapsible section for admin pages.
* Supports both controlled (isOpen/onToggle) and uncontrolled (defaultOpen) modes.
*/
export function CollapsibleSection({
title,
count,
defaultOpen = true,
isOpen: controlledOpen,
onToggle,
children,
}: CollapsibleSectionProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
const toggle = onToggle ?? (() => setInternalOpen((v) => !v));
return (
<div className="rounded-xl border border-neutral-200 bg-neutral-100/50 overflow-hidden dark:border-white/10 dark:bg-neutral-800/50">
<button
type="button"
onClick={toggle}
aria-expanded={open}
className="flex items-center justify-between w-full px-5 py-3.5 text-left cursor-pointer group hover:bg-neutral-100 transition-colors dark:hover:bg-white/[0.02]"
>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-neutral-700 group-hover:text-neutral-900 transition-colors dark:text-neutral-200 dark:group-hover:text-white">
{title}
</h3>
{count !== undefined && (
<span className="text-xs text-neutral-500">{count}</span>
)}
</div>
<ChevronDown
size={16}
className={`text-neutral-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</button>
<div
className="grid transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<div className="px-5 pb-5 space-y-4">{children}</div>
</div>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { AlertTriangle, X } from "lucide-react";
import { useFocusTrap } from "@/hooks/useFocusTrap";
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
destructive?: boolean;
}
export function ConfirmDialog({
open,
title,
message,
confirmLabel = "Удалить",
cancelLabel = "Отмена",
onConfirm,
onCancel,
destructive = true,
}: ConfirmDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(null);
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
useEffect(() => {
if (!open) return;
cancelRef.current?.focus();
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onCancel();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onCancel]);
if (!open) return null;
return createPortal(
<div
ref={focusTrapRef}
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
role="alertdialog"
aria-modal="true"
aria-label={title}
onClick={onCancel}
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-neutral-900"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onCancel}
aria-label="Закрыть"
className="absolute right-3 top-3 rounded-full p-1 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
>
<X size={16} />
</button>
<div className="flex items-start gap-3">
{destructive && (
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-500/10">
<AlertTriangle size={20} className="text-red-400" />
</div>
)}
<div>
<h3 className="text-base font-bold text-neutral-900 dark:text-white">{title}</h3>
<p className="mt-1.5 text-sm text-neutral-600 dark:text-neutral-400">{message}</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<button
ref={cancelRef}
onClick={onCancel}
className="rounded-lg px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 transition-colors cursor-pointer dark:text-neutral-300 dark:hover:bg-white/[0.06]"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition-colors cursor-pointer ${
destructive
? "bg-red-600 text-white hover:bg-red-500"
: "bg-gold text-black hover:bg-gold-light"
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body
);
}
+454 -281
View File
@@ -1,7 +1,8 @@
import { useRef, useEffect, useState } from "react";
import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
import { useRef, useEffect, useState, useMemo, useCallback } from "react";
import { Plus, X, Upload, Loader2, Link, ImageIcon, AlertCircle, Bold, Italic, List, Heading2, Pencil } from "lucide-react";
import { formatMarkup } from "@/lib/markup";
import { adminFetch } from "@/lib/csrf";
import type { RichListItem, VictoryItem } from "@/types/content";
import type { RichListItem } from "@/types/content";
interface InputFieldProps {
label: string;
@@ -11,7 +12,11 @@ interface InputFieldProps {
type?: "text" | "url" | "tel";
}
const inputCls = "w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors";
const baseInput = "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500";
const textAreaInput = `${baseInput} resize-none overflow-hidden`;
const smallInput = "rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600";
const dashedInput = "flex-1 rounded-lg border border-dashed border-neutral-200 bg-neutral-100/50 px-4 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors dark:border-white/10 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-600";
const inputCls = baseInput;
export function InputField({
label,
@@ -25,7 +30,7 @@ export function InputField({
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<input
type={type}
value={value}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={inputCls}
@@ -82,16 +87,20 @@ export function ParticipantLimits({
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Мин. участников</label>
<input type="number" min={0} value={minStr} onChange={(e) => handleMin(e.target.value)}
aria-describedby="min-hint"
aria-invalid={minEmpty || undefined}
className={`${inputCls} ${minEmpty ? "!border-red-500/50" : ""}`} />
<p className={`text-[10px] mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
<p id="min-hint" className={`text-xs mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
{minEmpty ? "Поле не может быть пустым" : "Если записей меньше — занятие можно отменить"}
</p>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Макс. участников</label>
<input type="number" min={0} value={maxStr} onChange={(e) => handleMax(e.target.value)}
aria-describedby="max-hint"
aria-invalid={(maxEmpty || (maxLocal > 0 && minLocal > maxLocal)) || undefined}
className={`${inputCls} ${maxEmpty || (maxLocal > 0 && minLocal > maxLocal) ? "!border-red-500/50" : ""}`} />
<p className={`text-[10px] mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
<p id="max-hint" className={`text-xs mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
{maxEmpty ? "Поле не может быть пустым" : maxLocal > 0 && minLocal > maxLocal ? "Макс. не может быть меньше мин." : "0 = без лимита. При заполнении — лист ожидания"}
</p>
</div>
@@ -139,22 +148,268 @@ export function TextareaField({
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<textarea
ref={ref}
value={value}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none overflow-hidden"
className={textAreaInput}
/>
</div>
);
}
interface RichTextareaProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
}
export function RichTextarea({
label,
value,
onChange,
placeholder,
rows = 4,
}: RichTextareaProps) {
const ref = useRef<HTMLTextAreaElement>(null);
const [editing, setEditing] = useState(false);
const hasContent = Boolean(value?.trim());
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}, [value]);
useEffect(() => {
function onResize() {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
const wrapSelection = useCallback((before: string, after: string) => {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const text = value ?? "";
const selected = text.slice(start, end);
// If already wrapped, unwrap
const beforeCheck = text.slice(Math.max(0, start - before.length), start);
const afterCheck = text.slice(end, end + after.length);
if (beforeCheck === before && afterCheck === after) {
const newText = text.slice(0, start - before.length) + selected + text.slice(end + after.length);
onChange(newText);
requestAnimationFrame(() => {
el.selectionStart = start - before.length;
el.selectionEnd = end - before.length;
el.focus();
});
return;
}
const newText = text.slice(0, start) + before + selected + after + text.slice(end);
onChange(newText);
requestAnimationFrame(() => {
if (selected) {
el.selectionStart = start + before.length;
el.selectionEnd = end + before.length;
} else {
el.selectionStart = start + before.length;
el.selectionEnd = start + before.length;
}
el.focus();
});
}, [value, onChange]);
const insertAtCursor = useCallback((text: string) => {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const current = value ?? "";
// Add newline before if not at start of line
const lineStart = current.lastIndexOf("\n", start - 1) + 1;
const prefix = start > lineStart ? "\n" : "";
const newText = current.slice(0, start) + prefix + text + current.slice(start);
onChange(newText);
const cursorPos = start + prefix.length + text.length;
requestAnimationFrame(() => {
el.selectionStart = cursorPos;
el.selectionEnd = cursorPos;
el.focus();
});
}, [value, onChange]);
// Track active formatting at cursor position
const [cursorPos, setCursorPos] = useState<{ start: number; end: number }>({ start: 0, end: 0 });
const updateCursorPos = useCallback(() => {
const el = ref.current;
if (!el) return;
setCursorPos({ start: el.selectionStart, end: el.selectionEnd });
}, []);
const isBold = useMemo(() => {
const text = value ?? "";
const { start, end } = cursorPos;
if (start !== end) {
// Check if selection is wrapped in **
return text.slice(Math.max(0, start - 2), start) === "**" && text.slice(end, end + 2) === "**";
}
// Check if cursor is inside **...**
const before = text.slice(0, start);
const after = text.slice(start);
const lastOpen = before.lastIndexOf("**");
if (lastOpen === -1) return false;
const betweenOpen = before.slice(lastOpen + 2);
if (betweenOpen.includes("**")) return false;
return after.indexOf("**") !== -1;
}, [value, cursorPos]);
const isItalic = useMemo(() => {
const text = value ?? "";
const { start, end } = cursorPos;
if (start !== end) {
const cb = text[start - 1];
const ca = text[end];
return cb === "*" && ca === "*" && text[start - 2] !== "*" && text[end + 1] !== "*";
}
const before = text.slice(0, start);
const after = text.slice(start);
// Find single * (not **) before cursor
const lastStar = before.lastIndexOf("*");
if (lastStar === -1) return false;
if (lastStar > 0 && before[lastStar - 1] === "*") return false;
const nextStar = after.indexOf("*");
if (nextStar === -1) return false;
if (after[nextStar + 1] === "*") return false;
return true;
}, [value, cursorPos]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// Use e.code for layout-independent shortcuts (works with Russian layout)
if ((e.ctrlKey || e.metaKey) && e.code === "KeyB") {
e.preventDefault();
wrapSelection("**", "**");
}
if ((e.ctrlKey || e.metaKey) && e.code === "KeyI") {
e.preventDefault();
wrapSelection("*", "*");
}
}, [wrapSelection]);
const toolbarBtn = (active: boolean) =>
`rounded p-1.5 transition-colors ${
active
? "text-gold bg-gold/15"
: "text-neutral-500 hover:text-neutral-900 hover:bg-neutral-200 dark:hover:text-white dark:hover:bg-white/10"
}`;
// Preview mode: show rendered markup
if (!editing && hasContent) {
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div
onClick={() => {
setEditing(true);
requestAnimationFrame(() => ref.current?.focus());
}}
className="group rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 cursor-text hover:border-gold/30 transition-colors relative dark:border-white/10 dark:bg-neutral-800"
>
<div className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
{formatMarkup(value)}
</div>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="flex items-center gap-1 text-xs text-neutral-500">
<Pencil size={10} />
редактировать
</span>
</div>
</div>
</div>
);
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="rounded-lg border border-neutral-200 bg-neutral-100 overflow-hidden hover:border-gold/30 focus-within:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800">
{/* Toolbar */}
<div className="flex items-center gap-0.5 px-2 py-1 border-b border-neutral-200 dark:border-white/5">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => wrapSelection("**", "**")}
className={toolbarBtn(isBold)}
title="Жирный (Ctrl+B)"
>
<Bold size={14} />
</button>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => wrapSelection("*", "*")}
className={toolbarBtn(isItalic)}
title="Курсив (Ctrl+I)"
>
<Italic size={14} />
</button>
<div className="w-px h-4 bg-white/10 mx-1" />
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertAtCursor("🤍 ")}
className={toolbarBtn(false)}
title="Пункт списка"
>
<List size={14} />
</button>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => wrapSelection("## ", "")}
className={toolbarBtn(false)}
title="Подзаголовок"
>
<Heading2 size={14} />
</button>
</div>
{/* Textarea */}
<textarea
ref={ref}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onKeyUp={updateCursorPos}
onClick={updateCursorPos}
onSelect={updateCursorPos}
onBlur={() => setEditing(false)}
placeholder={placeholder}
rows={rows}
className="w-full bg-transparent px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none resize-none dark:text-white dark:placeholder-neutral-500"
/>
</div>
</div>
);
}
interface SelectFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
hint?: string;
}
export function SelectField({
@@ -163,9 +418,11 @@ export function SelectField({
onChange,
options,
placeholder,
hint,
}: SelectFieldProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [highlightIndex, setHighlightIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -177,6 +434,33 @@ export function SelectField({
})
: options;
const showSearch = options.length > 3;
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
setSearch("");
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) { setOpen(true); setHighlightIndex(0); return; }
setHighlightIndex((prev) => (prev + 1) % filtered.length);
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (!open) { setOpen(true); setHighlightIndex(filtered.length - 1); return; }
setHighlightIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
}
if (e.key === "Enter" && open && highlightIndex >= 0 && highlightIndex < filtered.length) {
e.preventDefault();
onChange(filtered[highlightIndex].value);
setOpen(false);
setSearch("");
setHighlightIndex(-1);
}
}
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
@@ -191,51 +475,74 @@ export function SelectField({
return (
<div ref={containerRef} className="relative">
{label && <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>}
<button
type="button"
onClick={() => {
setOpen(!open);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}}
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
>
{selectedLabel || placeholder || "Выберите..."}
</button>
{open && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
{options.length > 3 && (
<div className="p-1.5">
{label && (
<label className="flex items-center gap-1.5 text-sm text-neutral-400 mb-1.5">
{label}
{hint && (
<span className="group relative">
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-neutral-300 text-[10px] text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors cursor-help dark:border-white/15 dark:hover:text-white dark:hover:border-white/30">?</span>
<span className="absolute left-6 top-1/2 -translate-y-1/2 z-50 w-52 rounded-lg border border-neutral-200 bg-white px-3 py-2 text-[11px] leading-relaxed text-neutral-700 shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300">
{hint}
</span>
</span>
)}
</label>
)}
{showSearch ? (
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск..."
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
value={open ? search : selectedLabel}
onChange={(e) => { setSearch(e.target.value); if (!open) setOpen(true); setHighlightIndex(0); }}
onFocus={() => { setOpen(true); setSearch(""); }}
onKeyDown={handleKeyDown}
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
placeholder={placeholder || "Выберите..."}
className={`w-full rounded-lg border bg-neutral-100 text-neutral-900 outline-none transition-colors dark:bg-neutral-800 dark:text-white ${
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
} ${open ? "border-gold" : "border-neutral-200 dark:border-white/10"} placeholder-neutral-400 dark:placeholder-neutral-500`}
/>
</div>
) : (
<button
type="button"
onClick={() => setOpen(!open)}
onKeyDown={handleKeyDown}
aria-expanded={open}
aria-haspopup="listbox"
className={`w-full rounded-lg border bg-neutral-100 text-left outline-none transition-colors dark:bg-neutral-800 ${
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
} ${open ? "border-gold" : "border-neutral-200 dark:border-white/10"} ${value ? "text-neutral-900 dark:text-white" : "text-neutral-500"}`}
>
{selectedLabel || placeholder || "Выберите..."}
</button>
)}
{open && (
<div role="listbox" className="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/10 dark:bg-neutral-800">
<div className="max-h-48 overflow-y-auto">
{filtered.length === 0 && (
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
)}
{filtered.map((opt) => (
{filtered.map((opt, idx) => (
<button
key={opt.value}
key={opt.value || `opt-${idx}`}
type="button"
role="option"
aria-selected={opt.value === value}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setHighlightIndex(idx)}
onClick={() => {
onChange(opt.value);
setOpen(false);
setSearch("");
setHighlightIndex(-1);
inputRef.current?.blur();
}}
className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-white/5 ${
opt.value === value ? "text-gold bg-gold/5" : "text-white"
}`}
className={`w-full px-4 py-2 text-left text-sm transition-colors ${
idx === highlightIndex ? "bg-neutral-100 dark:bg-white/10" : "hover:bg-neutral-50 dark:hover:bg-white/5"
} ${opt.value === value ? "text-gold bg-gold/5" : "text-neutral-900 dark:text-white"}`}
>
{opt.label}
</button>
@@ -278,7 +585,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
}
function handleEndChange(newEnd: string) {
if (start && newEnd && newEnd <= start) return;
// Always allow the change — validation handles the error display
update(start, newEnd);
}
@@ -291,7 +598,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
value={start}
onChange={(e) => handleStartChange(e.target.value)}
onBlur={onBlur}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2.5 text-neutral-900 outline-none focus:border-gold transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]"
/>
<span className="text-neutral-500"></span>
<input
@@ -299,7 +606,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
value={end}
onChange={(e) => handleEndChange(e.target.value)}
onBlur={onBlur}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2.5 text-neutral-900 outline-none focus:border-gold transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]"
/>
</div>
</div>
@@ -370,7 +677,7 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
type="text"
value={item}
onChange={(e) => update(i, e.target.value)}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2 text-sm text-white outline-none focus:border-gold transition-colors"
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
/>
<button
type="button"
@@ -387,8 +694,9 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
onBlur={add}
placeholder={placeholder || "Добавить..."}
className="flex-1 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold/50 transition-colors"
className={dashedInput}
/>
<button
type="button"
@@ -410,11 +718,13 @@ interface VictoryListFieldProps {
onChange: (items: RichListItem[]) => void;
placeholder?: string;
onLinkValidate?: (key: string, error: string | null) => void;
onUploadComplete?: () => void;
}
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate }: VictoryListFieldProps) {
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate, onUploadComplete }: VictoryListFieldProps) {
const [draft, setDraft] = useState("");
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
const [uploadError, setUploadError] = useState("");
function add() {
const val = draft.trim();
@@ -443,6 +753,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
const file = e.target.files?.[0];
if (!file) return;
setUploadingIndex(index);
setUploadError("");
const formData = new FormData();
formData.append("file", file);
formData.append("folder", "team");
@@ -451,8 +762,13 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
const result = await res.json();
if (result.path) {
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
onUploadComplete?.();
} else {
setUploadError(result.error || "Ошибка загрузки");
}
} catch { /* upload failed */ } finally {
} catch {
setUploadError("Не удалось загрузить файл");
} finally {
setUploadingIndex(null);
}
}
@@ -462,13 +778,13 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="space-y-2">
{items.map((item, i) => (
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5">
<div key={i} className="rounded-lg border border-neutral-200 bg-neutral-100/80 p-2.5 space-y-1.5 transition-colors hover:border-gold/30 hover:bg-neutral-200/80 focus-within:border-gold/50 focus-within:bg-neutral-200 dark:border-white/10 dark:bg-neutral-800/50 dark:hover:bg-neutral-800/80 dark:focus-within:bg-neutral-800">
<div className="flex items-center gap-1.5">
<input
type="text"
value={item.text}
onChange={(e) => updateText(i, e.target.value)}
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white outline-none focus:border-gold transition-colors"
className="flex-1 rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
/>
<button
type="button"
@@ -480,7 +796,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
</div>
<div className="flex items-center gap-1.5">
{item.image ? (
<div className="flex items-center gap-1 rounded bg-neutral-700/50 px-1.5 py-0.5 text-[11px] text-neutral-300">
<div className="flex items-center gap-1 rounded bg-neutral-200 px-1.5 py-0.5 text-[11px] text-neutral-700 dark:bg-neutral-700/50 dark:text-neutral-300">
<ImageIcon size={10} className="text-gold" />
<span className="max-w-[80px] truncate">{item.image.split("/").pop()}</span>
<button type="button" onClick={() => removeImage(i)} className="text-neutral-500 hover:text-red-400">
@@ -509,8 +825,9 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
onBlur={add}
placeholder={placeholder || "Добавить..."}
className="flex-1 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold/50 transition-colors"
className={dashedInput}
/>
<button
type="button"
@@ -522,150 +839,8 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
</button>
</div>
</div>
</div>
);
}
// --- Date Range Picker ---
// Parses Russian date formats: "22.02.2025", "22-23.02.2025", "22.02-01.03.2025"
function parseDateRange(value: string): { start: string; end: string } {
if (!value) return { start: "", end: "" };
// "22-23.02.2025" → same month range
const sameMonth = value.match(/^(\d{1,2})-(\d{1,2})\.(\d{2})\.(\d{4})$/);
if (sameMonth) {
const [, d1, d2, m, y] = sameMonth;
return {
start: `${y}-${m}-${d1.padStart(2, "0")}`,
end: `${y}-${m}-${d2.padStart(2, "0")}`,
};
}
// "22.02-01.03.2025" → cross-month range
const crossMonth = value.match(/^(\d{1,2})\.(\d{2})-(\d{1,2})\.(\d{2})\.(\d{4})$/);
if (crossMonth) {
const [, d1, m1, d2, m2, y] = crossMonth;
return {
start: `${y}-${m1}-${d1.padStart(2, "0")}`,
end: `${y}-${m2}-${d2.padStart(2, "0")}`,
};
}
// "22.02.2025" → single date
const single = value.match(/^(\d{1,2})\.(\d{2})\.(\d{4})$/);
if (single) {
const [, d, m, y] = single;
const iso = `${y}-${m}-${d.padStart(2, "0")}`;
return { start: iso, end: "" };
}
return { start: "", end: "" };
}
function formatDateRange(start: string, end: string): string {
if (!start) return "";
const [sy, sm, sd] = start.split("-");
if (!end) return `${sd}.${sm}.${sy}`;
const [ey, em, ed] = end.split("-");
if (sm === em && sy === ey) return `${sd}-${ed}.${sm}.${sy}`;
return `${sd}.${sm}-${ed}.${em}.${ey}`;
}
interface DateRangeFieldProps {
value: string;
onChange: (value: string) => void;
}
export function DateRangeField({ value, onChange }: DateRangeFieldProps) {
const { start, end } = parseDateRange(value);
function handleChange(s: string, e: string) {
onChange(formatDateRange(s, e));
}
return (
<div className="flex items-center gap-1">
<Calendar size={11} className="text-neutral-500 shrink-0" />
<input
type="date"
value={start}
onChange={(e) => handleChange(e.target.value, end)}
className="w-[130px] rounded-md border border-white/10 bg-neutral-800 px-1.5 py-1.5 text-xs text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
/>
<span className="text-neutral-500 text-xs"></span>
<input
type="date"
value={end}
min={start}
onChange={(e) => handleChange(start, e.target.value)}
className="w-[130px] rounded-md border border-white/10 bg-neutral-800 px-1.5 py-1.5 text-xs text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
/>
</div>
);
}
// --- City Autocomplete Field ---
interface CityFieldProps {
value: string;
onChange: (value: string) => void;
error?: string;
onSearch?: (query: string) => void;
suggestions?: string[];
onSelectSuggestion?: (value: string) => void;
}
export function CityField({ value, onChange, error, onSearch, suggestions, onSelectSuggestion }: CityFieldProps) {
const [focused, setFocused] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!focused) return;
function handle(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setFocused(false);
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [focused]);
return (
<div ref={containerRef} className="relative flex-1">
<div className="relative">
<MapPin size={11} className="absolute left-2 top-1/2 -translate-y-1/2 text-neutral-500 pointer-events-none" />
<input
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
onSearch?.(e.target.value);
}}
onFocus={() => setFocused(true)}
placeholder="Город, страна"
className={`w-full rounded-md border bg-neutral-800 pl-6 pr-3 py-1.5 text-sm text-white placeholder-neutral-600 outline-none transition-colors ${
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
}`}
/>
{error && <AlertCircle size={12} className="absolute right-2 top-1/2 -translate-y-1/2 text-red-400" />}
</div>
{error && <p className="mt-0.5 text-[10px] text-red-400">{error}</p>}
{focused && suggestions && suggestions.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
{suggestions.map((s) => (
<button
key={s}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onSelectSuggestion?.(s);
setFocused(false);
}}
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-white/5 transition-colors"
>
{s}
</button>
))}
</div>
{uploadError && (
<p role="alert" className="mt-1.5 text-xs text-red-400">{uploadError}</p>
)}
</div>
);
@@ -711,8 +886,8 @@ export function ValidatedLinkField({ value, onChange, onValidate, validationKey,
validate(e.target.value);
}}
placeholder={placeholder || "Ссылка..."}
className={`w-full rounded-md border bg-neutral-800 px-2 py-1 text-xs text-white placeholder-neutral-600 outline-none transition-colors ${
error ? "border-red-500/50" : "border-white/5 focus:border-gold/50"
className={`w-full rounded-md border bg-neutral-100 px-2 py-1 text-xs text-neutral-900 placeholder-neutral-400 outline-none transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600 ${
error ? "border-red-500/50" : "border-neutral-200 focus:border-gold/50 dark:border-white/5"
}`}
/>
{error && (
@@ -725,106 +900,104 @@ export function ValidatedLinkField({ value, onChange, onValidate, validationKey,
);
}
interface VictoryItemListFieldProps {
// --- Autocomplete Multi-Select ---
export function AutocompleteMulti({
label,
value,
onChange,
options,
placeholder,
}: {
label: string;
items: VictoryItem[];
onChange: (items: VictoryItem[]) => void;
cityErrors?: Record<number, string>;
citySuggestions?: { index: number; items: string[] } | null;
onCitySearch?: (index: number, query: string) => void;
onCitySelect?: (index: number, value: string) => void;
onLinkValidate?: (key: string, error: string | null) => void;
value: string;
onChange: (v: string) => void;
options: string[];
placeholder?: string;
}) {
const selected = useMemo(() => (value ? value.split(/\s*[,·]\s*/).filter(Boolean) : []), [value]);
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
if (!query) return options.filter((o) => !selected.includes(o));
const q = query.toLowerCase();
return options.filter((o) => !selected.includes(o) && o.toLowerCase().includes(q));
}, [query, options, selected]);
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
function addItem(item: string) {
onChange([...selected, item].join(" · "));
setQuery("");
inputRef.current?.focus();
}
export function VictoryItemListField({ label, items, onChange, cityErrors, citySuggestions, onCitySearch, onCitySelect, onLinkValidate }: VictoryItemListFieldProps) {
function add() {
onChange([...items, { type: "place", place: "", category: "", competition: "" }]);
function removeItem(item: string) {
onChange(selected.filter((s) => s !== item).join(" · "));
}
function remove(index: number) {
onChange(items.filter((_, i) => i !== index));
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
if (filtered.length > 0) addItem(filtered[0]);
else if (query.trim()) addItem(query.trim());
}
function update(index: number, field: keyof VictoryItem, value: string) {
onChange(items.map((item, i) => (i === index ? { ...item, [field]: value || undefined } : item)));
if (e.key === "Backspace" && !query && selected.length > 0) {
removeItem(selected[selected.length - 1]);
}
if (e.key === "Escape") { setOpen(false); setQuery(""); }
}
return (
<div>
<div ref={containerRef} className="relative">
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="space-y-3">
{items.map((item, i) => (
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5">
<div className="flex gap-1.5">
<select
value={item.type || "place"}
onChange={(e) => update(i, "type", e.target.value)}
className="w-32 shrink-0 rounded-md border border-white/10 bg-neutral-800 px-2 py-1.5 text-sm text-white outline-none focus:border-gold transition-colors"
<div
onClick={() => { setOpen(true); inputRef.current?.focus(); }}
className={`flex flex-wrap items-center gap-1.5 rounded-lg border bg-neutral-100 px-3 py-2 min-h-[42px] cursor-text transition-colors dark:bg-neutral-800 ${
open ? "border-gold" : "border-neutral-200 hover:border-gold/30 dark:border-white/10"
}`}
>
<option value="place">Место</option>
<option value="nomination">Номинация</option>
<option value="judge">Судейство</option>
</select>
<input
type="text"
value={item.place || ""}
onChange={(e) => update(i, "place", e.target.value)}
placeholder="1 место, финалист..."
className="w-28 shrink-0 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
/>
<input
type="text"
value={item.category || ""}
onChange={(e) => update(i, "category", e.target.value)}
placeholder="Категория"
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
/>
<input
type="text"
value={item.competition || ""}
onChange={(e) => update(i, "competition", e.target.value)}
placeholder="Чемпионат"
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
/>
<button
type="button"
onClick={() => remove(i)}
className="shrink-0 rounded-md p-1.5 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
{selected.map((item) => (
<span key={item} className="inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/30 px-2.5 py-0.5 text-xs font-medium text-gold">
{item}
<button type="button" onClick={(e) => { e.stopPropagation(); removeItem(item); }} className="text-gold/60 hover:text-gold transition-colors">
<X size={10} />
</button>
</div>
<div className="flex gap-1.5">
<CityField
value={item.location || ""}
onChange={(v) => update(i, "location", v)}
error={cityErrors?.[i]}
onSearch={(q) => onCitySearch?.(i, q)}
suggestions={citySuggestions?.index === i ? citySuggestions.items : undefined}
onSelectSuggestion={(v) => onCitySelect?.(i, v)}
/>
<DateRangeField
value={item.date || ""}
onChange={(v) => update(i, "date", v)}
/>
</div>
<ValidatedLinkField
value={item.link || ""}
onChange={(v) => update(i, "link", v)}
validationKey={`victory-${i}`}
onValidate={onLinkValidate}
/>
</div>
</span>
))}
<button
type="button"
onClick={add}
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
>
<Plus size={14} />
Добавить достижение
</button>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={selected.length === 0 ? placeholder : ""}
className="flex-1 min-w-[80px] bg-transparent text-sm text-neutral-900 placeholder-neutral-400 outline-none dark:text-white dark:placeholder-neutral-500"
/>
</div>
{open && filtered.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden max-h-48 overflow-y-auto dark:border-white/10 dark:bg-neutral-800">
{filtered.map((opt) => (
<button key={opt} type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => addItem(opt)}
className="w-full px-4 py-2 text-left text-sm text-neutral-900 hover:bg-neutral-50 transition-colors dark:text-white dark:hover:bg-white/5">
{opt}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,184 @@
"use client";
import { useState, useRef, useEffect } from "react";
import Image from "next/image";
import { Upload, Loader2, ImageIcon } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
interface ImageCropData {
image: string;
focalX: number;
focalY: number;
zoom: number;
}
interface ImageCropFieldProps extends ImageCropData {
folder: string;
onChange: (data: ImageCropData) => void;
/** Aspect ratio CSS class for the preview. Default: "aspect-[16/9]" */
aspect?: string;
/** Max width CSS class for the preview container. Default: "max-w-3xl" */
maxWidth?: string;
label?: string;
}
export function ImageCropField({
image,
focalX,
focalY,
zoom,
folder,
onChange,
aspect = "aspect-[16/9]",
maxWidth = "max-w-3xl",
label = "Фото",
}: ImageCropFieldProps) {
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState("");
const [dragging, setDragging] = useState(false);
const dragStartRef = useRef({ x: 0, y: 0, startFocalX: 0, startFocalY: 0 });
const containerRef = useRef<HTMLDivElement>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setUploadError("");
const formData = new FormData();
formData.append("file", file);
formData.append("folder", folder);
try {
const res = await adminFetch("/api/admin/upload", {
method: "POST",
body: formData,
});
const result = await res.json();
if (result.path) {
onChange({ image: result.path, focalX: 50, focalY: 50, zoom: 1 });
} else {
setUploadError(result.error || "Ошибка загрузки");
}
} catch {
setUploadError("Не удалось загрузить файл");
} finally {
setUploading(false);
}
}
function handlePointerDown(e: React.PointerEvent) {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragging(true);
dragStartRef.current = { x: e.clientX, y: e.clientY, startFocalX: focalX, startFocalY: focalY };
}
function handlePointerMove(e: React.PointerEvent) {
if (!dragging) return;
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const { x: startX, y: startY, startFocalX, startFocalY } = dragStartRef.current;
const dx = ((e.clientX - startX) / rect.width) * 100;
const dy = ((e.clientY - startY) / rect.height) * 100;
onChange({
image,
focalX: Math.round(Math.max(0, Math.min(100, startFocalX - dx))),
focalY: Math.round(Math.max(0, Math.min(100, startFocalY - dy))),
zoom,
});
}
function handlePointerUp() { setDragging(false); }
// Attach wheel as non-passive to allow preventDefault (stops page scroll)
useEffect(() => {
const el = containerRef.current;
if (!el) return;
function onWheel(e: WheelEvent) {
if (!e.ctrlKey && !e.metaKey) return; // Only zoom with Ctrl+scroll
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
onChange({ image, focalX, focalY, zoom: Math.round(Math.max(1, Math.min(3, zoom + delta)) * 10) / 10 });
}
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [zoom, focalX, focalY, image, onChange]);
return (
<div>
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">
{label} <span className="text-neutral-400 dark:text-neutral-600">(перетащите · Ctrl+колёсико для масштаба)</span>
</label>
{image ? (
<div className={`${maxWidth} space-y-2`}>
<div
ref={containerRef}
className={`relative ${aspect} overflow-hidden rounded-lg border border-neutral-200 cursor-grab active:cursor-grabbing select-none dark:border-white/10`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<Image
src={image}
alt="Превью"
fill
className="object-cover pointer-events-none"
style={{
objectPosition: `${focalX}% ${focalY}%`,
transform: `scale(${zoom})`,
}}
sizes="384px"
unoptimized
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500"></span>
<input
type="range"
min="1"
max="3"
step="0.1"
value={zoom}
onChange={(e) => onChange({ image, focalX, focalY, zoom: parseFloat(e.target.value) })}
className="flex-1 h-1 accent-[#c9a96e] cursor-pointer"
/>
<span className="text-xs text-neutral-500">+</span>
{zoom > 1 && (
<button
type="button"
onClick={() => onChange({ image, focalX: 50, focalY: 50, zoom: 1 })}
className="text-xs text-neutral-500 hover:text-white transition-colors"
>
Сбросить
</button>
)}
</div>
<div className="flex items-center gap-2">
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-neutral-200 px-2.5 py-1 text-xs text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25">
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
Заменить
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
</label>
<button
type="button"
onClick={() => onChange({ image: "", focalX: 50, focalY: 50, zoom: 1 })}
className="rounded-md px-2.5 py-1 text-xs text-neutral-500 hover:text-red-400 transition-colors"
>
Удалить
</button>
</div>
</div>
) : (
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-neutral-500 hover:border-gold/30 hover:text-neutral-700 transition-colors dark:border-white/15 dark:hover:text-neutral-300">
{uploading ? <Loader2 size={14} className="animate-spin" /> : <ImageIcon size={14} />}
<span className="text-xs">{uploading ? "Загрузка..." : "Загрузить фото"}</span>
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
</label>
)}
{uploadError && (
<p role="alert" className="mt-1.5 text-xs text-red-400">{uploadError}</p>
)}
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
interface PriceFieldProps {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
}
export function PriceField({ label, value, onChange, placeholder = "0" }: PriceFieldProps) {
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
return (
<div>
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">{label}</label>
<div className="flex rounded-lg border border-neutral-200 bg-neutral-100 focus-within:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800">
<input
type="text"
inputMode="decimal"
value={raw}
onChange={(e) => {
const v = e.target.value.replace(/[^\d.,\s]/g, "");
onChange(v ? `${v} BYN` : "");
}}
placeholder={placeholder}
className="flex-1 bg-transparent px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none min-w-0 dark:text-white dark:placeholder-neutral-500"
/>
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
BYN
</span>
</div>
</div>
);
}
+53 -4
View File
@@ -7,6 +7,9 @@ import { adminFetch } from "@/lib/csrf";
interface SectionEditorProps<T> {
sectionKey: string;
title: string;
defaultData?: Partial<T>;
/** Return true if data is valid and can be saved. Blocks auto-save when false. */
validate?: (data: T) => boolean;
children: (data: T, update: (data: T) => void) => React.ReactNode;
}
@@ -15,14 +18,19 @@ const DEBOUNCE_MS = 800;
export function SectionEditor<T>({
sectionKey,
title,
defaultData,
validate,
children,
}: SectionEditorProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error" | "invalid">("idle");
const [error, setError] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLoadRef = useRef(true);
const pendingSaveRef = useRef(false);
const defaultDataRef = useRef(defaultData);
defaultDataRef.current = defaultData;
useEffect(() => {
adminFetch(`/api/admin/sections/${sectionKey}`)
@@ -30,7 +38,7 @@ export function SectionEditor<T>({
if (!r.ok) throw new Error("Failed to load");
return r.json();
})
.then(setData)
.then((loaded) => setData(defaultDataRef.current ? { ...defaultDataRef.current, ...loaded } as T : loaded))
.catch(() => setError("Не удалось загрузить данные"))
.finally(() => setLoading(false));
}, [sectionKey]);
@@ -63,8 +71,13 @@ export function SectionEditor<T>({
return;
}
pendingSaveRef.current = true;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (validate && !validate(data)) {
setStatus("invalid");
return;
}
save(data);
}, DEBOUNCE_MS);
@@ -73,6 +86,41 @@ export function SectionEditor<T>({
};
}, [data, save]);
// Clear pending flag after save completes
useEffect(() => {
if (status === "saved") pendingSaveRef.current = false;
}, [status]);
// Warn before leaving with unsaved changes
useEffect(() => {
function onBeforeUnload(e: BeforeUnloadEvent) {
if (pendingSaveRef.current) e.preventDefault();
}
function onLinkClick(e: MouseEvent) {
if (!pendingSaveRef.current) return;
const link = (e.target as HTMLElement).closest("a");
if (!link || link.target === "_blank") return;
const href = link.getAttribute("href");
if (!href || href.startsWith("#")) return;
// Force save immediately before navigating
if (timerRef.current) clearTimeout(timerRef.current);
if (data && (!validate || validate(data))) {
e.preventDefault();
e.stopPropagation();
save(data).then(() => {
pendingSaveRef.current = false;
window.location.href = href;
});
}
}
window.addEventListener("beforeunload", onBeforeUnload);
document.addEventListener("click", onLinkClick, true);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
document.removeEventListener("click", onLinkClick, true);
};
}, [data, save, validate]);
if (loading) {
return (
<div className="flex items-center gap-2 text-neutral-400">
@@ -91,14 +139,15 @@ export function SectionEditor<T>({
<h1 className="text-2xl font-bold">{title}</h1>
{/* Fixed toast popup */}
{(status === "saved" || status === "error") && (
<div className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
{(status === "saved" || status === "error" || status === "invalid") && (
<div role="status" aria-live="polite" className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
status === "saved"
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
: "bg-red-950/90 border-red-500/30 text-red-200"
}`}>
{status === "saved" && <><Check size={14} /> Сохранено</>}
{status === "error" && <><AlertCircle size={14} /> {error}</>}
{status === "invalid" && <><AlertCircle size={14} /> Не сохранено исправьте ошибки</>}
</div>
)}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useState, useEffect, useCallback, createContext, useContext } from "react";
import { X, AlertCircle, CheckCircle2 } from "lucide-react";
interface ToastItem {
id: number;
message: string;
type: "error" | "success";
}
interface ToastContextValue {
showError: (message: string) => void;
showSuccess: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue>({
showError: () => {},
showSuccess: () => {},
});
export function useToast() {
return useContext(ToastContext);
}
let nextId = 0;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const addToast = useCallback((message: string, type: "error" | "success") => {
const id = ++nextId;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
}, []);
const showError = useCallback((message: string) => addToast(message, "error"), [addToast]);
const showSuccess = useCallback((message: string) => addToast(message, "success"), [addToast]);
return (
<ToastContext.Provider value={{ showError, showSuccess }}>
{children}
{toasts.length > 0 && (
<div role="status" aria-live="polite" className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
{toasts.map((t) => (
<div
key={t.id}
className={`flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm shadow-lg animate-in slide-in-from-right ${
t.type === "error"
? "bg-red-950/90 border-red-500/30 text-red-200"
: "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
}`}
>
{t.type === "error" ? <AlertCircle size={14} className="shrink-0" /> : <CheckCircle2 size={14} className="shrink-0" />}
<span className="flex-1">{t.message}</span>
<button
aria-label="Закрыть уведомление"
onClick={() => setToasts((prev) => prev.filter((tt) => tt.id !== t.id))}
className="shrink-0 text-neutral-400 hover:text-white"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
</ToastContext.Provider>
);
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Admin UI Primitives
*
* Single source of truth for admin panel styling.
* Every input, select, button, badge, card, and modal in /admin should use these.
*/
import { type ComponentPropsWithoutRef, forwardRef } from "react";
import { X } from "lucide-react";
import { useFocusTrap } from "@/hooks/useFocusTrap";
/* ============================== */
/* Style tokens */
/* ============================== */
export const adminStyles = {
/** Standard input — full width, rounded-lg */
input:
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500",
/** Compact input — smaller padding, text-sm */
inputSm:
"rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600",
/** Dashed input — for "add new" fields */
inputDashed:
"flex-1 rounded-lg border border-dashed border-neutral-200 bg-neutral-100/50 px-4 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors dark:border-white/10 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-600",
/** Textarea — same as input + resize-none */
textarea:
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500",
/** Native select */
select:
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]",
/** Select option */
option: "bg-white dark:bg-neutral-900",
/** Primary button — gold solid */
btnPrimary:
"inline-flex items-center justify-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-all hover:bg-gold-light hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed",
/** Secondary button — outline */
btnSecondary:
"inline-flex items-center justify-center gap-2 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-200 dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700",
/** Small gold accent button */
btnGoldSm:
"rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-amber-700 hover:bg-gold/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed dark:text-gold",
/** Cancel/muted small button */
btnCancelSm:
"rounded-md border border-neutral-200 px-3 py-1 text-xs text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25",
/** Danger button */
btnDanger:
"inline-flex items-center justify-center gap-2 rounded-lg bg-red-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-red-500",
/** Card container */
card:
"rounded-xl border border-neutral-200 bg-white p-5 dark:border-white/10 dark:bg-neutral-900",
/** Modal overlay */
modalOverlay:
"fixed inset-0 z-50 flex items-center justify-center p-4",
/** Modal backdrop */
modalBackdrop:
"absolute inset-0 bg-black/70 backdrop-blur-sm",
/** Modal content */
modalContent:
"relative w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]",
/** Modal close button */
modalClose:
"absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 transition-colors cursor-pointer dark:hover:bg-white/[0.06] dark:hover:text-white",
/** Label */
label:
"block text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400",
/** Section heading in admin */
sectionTitle:
"text-lg font-bold text-neutral-900 dark:text-white",
/** Dashed add-item button */
addButton:
"flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors dark:border-white/20 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/40",
} as const;
/* ============================== */
/* Input components */
/* ============================== */
interface AdminInputProps extends ComponentPropsWithoutRef<"input"> {
variant?: "default" | "sm" | "dashed";
}
export const AdminInput = forwardRef<HTMLInputElement, AdminInputProps>(
function AdminInput({ variant = "default", className = "", ...props }, ref) {
const base =
variant === "sm" ? adminStyles.inputSm
: variant === "dashed" ? adminStyles.inputDashed
: adminStyles.input;
return <input ref={ref} className={`${base} ${className}`} {...props} />;
},
);
interface AdminTextareaProps extends ComponentPropsWithoutRef<"textarea"> {
autoResize?: boolean;
}
export const AdminTextarea = forwardRef<HTMLTextAreaElement, AdminTextareaProps>(
function AdminTextarea({ autoResize, className = "", ...props }, ref) {
function handleInput(e: React.FormEvent<HTMLTextAreaElement>) {
if (autoResize) {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}
}
return (
<textarea
ref={ref}
className={`${adminStyles.textarea} ${className}`}
{...props}
{...(autoResize ? { onInput: handleInput } : {})}
/>
);
},
);
interface AdminSelectProps extends ComponentPropsWithoutRef<"select"> {
children: React.ReactNode;
}
export const AdminSelect = forwardRef<HTMLSelectElement, AdminSelectProps>(
function AdminSelect({ className = "", children, ...props }, ref) {
return (
<select ref={ref} className={`${adminStyles.select} ${className}`} {...props}>
{children}
</select>
);
},
);
/* ============================== */
/* Button components */
/* ============================== */
interface AdminButtonProps extends ComponentPropsWithoutRef<"button"> {
variant?: "primary" | "secondary" | "danger" | "goldSm" | "cancelSm";
}
export function AdminButton({ variant = "primary", className = "", ...props }: AdminButtonProps) {
const base =
variant === "secondary" ? adminStyles.btnSecondary
: variant === "danger" ? adminStyles.btnDanger
: variant === "goldSm" ? adminStyles.btnGoldSm
: variant === "cancelSm" ? adminStyles.btnCancelSm
: adminStyles.btnPrimary;
return <button className={`${base} ${className}`} {...props} />;
}
/* ============================== */
/* Modal component */
/* ============================== */
interface AdminModalProps {
open: boolean;
onClose: () => void;
title?: string;
maxWidth?: string;
children: React.ReactNode;
}
export function AdminModal({ open, onClose, title, maxWidth = "max-w-sm", children }: AdminModalProps) {
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
if (!open) return null;
return (
<div className={adminStyles.modalOverlay} onClick={onClose}>
<div className={adminStyles.modalBackdrop} />
<div
ref={focusTrapRef}
className={`${adminStyles.modalContent} ${maxWidth}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={title}
>
<button onClick={onClose} className={adminStyles.modalClose} aria-label="Закрыть">
<X size={16} />
</button>
{title && <h3 className="text-sm font-bold text-neutral-900 dark:text-white mb-4">{title}</h3>}
{children}
</div>
</div>
);
}
+8 -6
View File
@@ -1,7 +1,7 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { InputField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
interface AboutData {
@@ -11,7 +11,7 @@ interface AboutData {
export default function AboutEditorPage() {
return (
<SectionEditor<AboutData> sectionKey="about" title="О студии">
<SectionEditor<AboutData> sectionKey="about" title="О студии" defaultData={{ paragraphs: [] }}>
{(data, update) => (
<>
<InputField
@@ -23,12 +23,14 @@ export default function AboutEditorPage() {
label="Параграфы"
items={data.paragraphs}
onChange={(paragraphs) => update({ ...data, paragraphs })}
inline
renderItem={(text, _i, updateItem) => (
<TextareaField
label={`Параграф`}
<textarea
value={text}
onChange={updateItem}
rows={3}
onChange={(e) => updateItem(e.target.value)}
rows={2}
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
placeholder="Текст параграфа..."
/>
)}
createItem={() => ""}
+220 -67
View File
@@ -1,16 +1,131 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { X, ChevronDown } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { formatBelarusPhone, SHORT_DAYS } from "@/lib/formatting";
type Tab = "classes" | "events";
type EventType = "master-class" | "open-day";
interface McOption { title: string; date: string }
interface OdClass { id: number; style: string; time: string; hall: string }
interface OdClass { id: number; style: string; start_time: string; hall: string; trainer: string }
interface OdEvent { id: number; date: string; title?: string }
interface ScheduleClass { type: string; trainer: string; time: string; day: string; hall: string; groupId?: string }
function shortName(fullName: string) {
const parts = fullName.trim().split(/\s+/);
// Names stored as "Имя Фамилия" → show "Фамилия И."
return parts.length > 1 ? `${parts[1]} ${parts[0][0]}.` : parts[0];
}
// --- Searchable dropdown ---
interface SearchSelectOption { value: string; label: string }
function SearchSelect({ options, value, onChange, placeholder }: {
options: SearchSelectOption[];
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selected = options.find((o) => o.value === value);
const filtered = search
? (() => {
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean);
return options.filter((o) => {
const label = o.label.toLowerCase();
return tokens.every((t) => label.includes(t));
});
})()
: options;
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
return (
<div ref={containerRef} className="relative">
<div
onClick={() => { setOpen(true); setTimeout(() => inputRef.current?.focus(), 0); }}
className={`flex items-center gap-2 w-full rounded-lg border px-3 py-2 text-sm cursor-text transition-colors ${
open ? "border-gold/40 bg-neutral-200/60 dark:bg-white/[0.06]" : "border-neutral-200 bg-neutral-100 dark:border-white/[0.08] dark:bg-white/[0.04]"
}`}
>
{open ? (
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={selected ? selected.label : placeholder}
className="flex-1 bg-transparent text-neutral-900 placeholder-neutral-400 outline-none text-sm dark:text-white dark:placeholder-neutral-500"
onKeyDown={(e) => {
if (e.key === "Escape") { setOpen(false); setSearch(""); }
if (e.key === "Backspace" && !search && value) { onChange(""); }
}}
/>
) : (
<span className={`flex-1 truncate ${selected ? "text-neutral-900 dark:text-white" : "text-neutral-500"}`}>
{selected ? selected.label : placeholder}
</span>
)}
{value && !open ? (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onChange(""); }}
className="text-neutral-500 hover:text-white transition-colors shrink-0"
>
<X size={14} />
</button>
) : (
<ChevronDown size={14} className={`text-neutral-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
)}
</div>
{open && (
<div className="absolute z-20 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/[0.08] dark:bg-[#141414]">
<div className="max-h-48 overflow-y-scroll admin-scrollbar">
{filtered.length === 0 && (
<p className="px-3 py-2 text-xs text-neutral-500">Ничего не найдено</p>
)}
{filtered.map((o) => (
<button
key={o.value}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => { onChange(o.value); setOpen(false); setSearch(""); }}
className={`w-full px-3 py-2 text-left text-sm transition-colors ${
o.value === value ? "bg-gold/10 text-gold" : "text-neutral-900 hover:bg-neutral-50 dark:text-white dark:hover:bg-white/[0.05]"
}`}
>
{o.label}
</button>
))}
</div>
</div>
)}
</div>
);
}
// --- Modal ---
export function AddBookingModal({
open,
@@ -32,13 +147,28 @@ export function AddBookingModal({
const [odClasses, setOdClasses] = useState<OdClass[]>([]);
const [odEventId, setOdEventId] = useState<number | null>(null);
const [odClassId, setOdClassId] = useState("");
const [scheduleClasses, setScheduleClasses] = useState<ScheduleClass[]>([]);
const [classGroup, setClassGroup] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId("");
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassGroup("");
// Fetch upcoming MCs (filter out expired)
// Fetch schedule classes
adminFetch("/api/admin/sections/schedule").then((r) => r.json()).then((data: { locations?: { name: string; days: { day: string; classes: { type: string; trainer: string; time: string; groupId?: string }[] }[] }[] }) => {
const classes: ScheduleClass[] = [];
for (const loc of data.locations || []) {
for (const day of loc.days) {
for (const cls of day.classes) {
classes.push({ type: cls.type, trainer: cls.trainer, time: cls.time, day: day.day, hall: loc.name, groupId: cls.groupId });
}
}
}
setScheduleClasses(classes);
}).catch(() => {});
// Fetch upcoming MCs
adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()).then((data: { items?: { title: string; slots: { date: string }[] }[] }) => {
const today = new Date().toISOString().split("T")[0];
const upcoming = (data.items || [])
@@ -74,34 +204,61 @@ export function AddBookingModal({
}, [open, onClose]);
function handlePhoneChange(raw: string) {
let digits = raw.replace(/\D/g, "");
if (!digits.startsWith("375")) digits = "375" + digits.replace(/^375?/, "");
digits = digits.slice(0, 12);
let formatted = "+375";
const rest = digits.slice(3);
if (rest.length > 0) formatted += " (" + rest.slice(0, 2);
if (rest.length >= 2) formatted += ") ";
if (rest.length > 2) formatted += rest.slice(2, 5);
if (rest.length > 5) formatted += "-" + rest.slice(5, 7);
if (rest.length > 7) formatted += "-" + rest.slice(7, 9);
setPhone(formatted);
setPhone(formatBelarusPhone(raw));
}
const hasUpcomingMc = mcOptions.length > 0;
const hasOpenDay = odEventId !== null && odClasses.length > 0;
const hasEvents = hasUpcomingMc || hasOpenDay;
// Flat group options: one searchable dropdown
const classGroupOptions = useMemo((): SearchSelectOption[] => {
const byKey = new Map<string, { type: string; trainer: string; hall: string; slots: { day: string; time: string }[]; id: string }>();
for (const c of scheduleClasses) {
const id = c.groupId || `${c.type}|${c.trainer}|${c.time}|${c.hall}`;
const existing = byKey.get(id);
if (existing) {
if (!existing.slots.some((s) => s.day === c.day)) existing.slots.push({ day: c.day, time: c.time });
} else {
byKey.set(id, { type: c.type, trainer: c.trainer, hall: c.hall, slots: [{ day: c.day, time: c.time }], id });
}
}
return [...byKey.values()].map((g) => {
const sameTime = g.slots.every((s) => s.time === g.slots[0].time);
const days = sameTime
? `${g.slots.map((s) => SHORT_DAYS[s.day] || s.day.slice(0, 2)).join("/")} ${g.slots[0].time}`
: g.slots.map((s) => `${SHORT_DAYS[s.day] || s.day.slice(0, 2)} ${s.time}`).join(", ");
return {
value: g.id,
label: `${shortName(g.trainer)} · ${g.type} · ${days} · ${g.hall}`,
};
}).sort((a, b) => a.label.localeCompare(b.label));
}, [scheduleClasses]);
const mcSelectOptions: SearchSelectOption[] = mcOptions.map((mc) => ({
value: mc.title,
label: mc.title,
}));
const odSelectOptions: SearchSelectOption[] = odClasses.map((c) => ({
value: String(c.id),
label: `${shortName(c.trainer)}${c.start_time} · ${c.hall}`,
}));
async function handleSubmit() {
if (!name.trim() || !phone.trim()) return;
setSaving(true);
try {
if (tab === "classes") {
const groupInfo = classGroup
? classGroupOptions.find((o) => o.value === classGroup)?.label
: undefined;
await adminFetch("/api/admin/group-bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name.trim(),
phone: phone.trim(),
...(groupInfo && { groupInfo }),
...(instagram.trim() && { instagram: instagram.trim() }),
...(telegram.trim() && { telegram: telegram.trim() }),
}),
@@ -122,6 +279,8 @@ export function AddBookingModal({
}
onAdded();
onClose();
} catch {
alert("Не удалось создать запись. Попробуйте ещё раз.");
} finally {
setSaving(false);
}
@@ -129,19 +288,7 @@ export function AddBookingModal({
if (!open) return null;
const inputClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 placeholder-neutral-500";
const tabBtn = (key: Tab, label: string, disabled?: boolean) => (
<button
key={key}
onClick={() => !disabled && setTab(key)}
disabled={disabled}
className={`flex-1 rounded-lg py-2 text-xs font-medium transition-all ${
tab === key ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
}`}
>
{label}
</button>
);
const inputClass = "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 placeholder-neutral-400 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500";
const canSubmit = name.trim() && phone.trim() && !saving
&& (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc)
@@ -150,29 +297,30 @@ export function AddBookingModal({
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
<div className="relative w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white">
<X size={16} />
</button>
<h3 className="text-base font-bold text-white">Добавить запись</h3>
<p className="mt-1 text-xs text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
<h3 className="text-base font-bold text-neutral-900 dark:text-white">Добавить запись</h3>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
<div className="mt-4 space-y-3">
{/* Tab: Classes vs Events */}
<div className="flex gap-2">
{tabBtn("classes", "Занятие")}
{tabBtn("events", "Мероприятие", !hasEvents)}
</div>
{/* Events sub-selector */}
{tab === "events" && (
<div className="flex gap-2">
{/* Type selector — single row */}
<div className="flex rounded-lg border border-neutral-200 bg-neutral-100 p-0.5 dark:border-white/[0.08] dark:bg-white/[0.03]">
<button
onClick={() => setTab("classes")}
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
tab === "classes" ? "bg-gold/20 text-gold shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
}`}
>
Занятие
</button>
{hasUpcomingMc && (
<button
onClick={() => setEventType("master-class")}
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
eventType === "master-class" ? "bg-purple-500/15 text-purple-400 border border-purple-500/30" : "bg-neutral-800/50 text-neutral-500 border border-white/5 hover:text-neutral-300"
onClick={() => { setTab("events"); setEventType("master-class"); }}
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
tab === "events" && eventType === "master-class" ? "bg-purple-500/15 text-purple-400 shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
}`}
>
Мастер-класс
@@ -180,39 +328,44 @@ export function AddBookingModal({
)}
{hasOpenDay && (
<button
onClick={() => setEventType("open-day")}
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
eventType === "open-day" ? "bg-blue-500/15 text-blue-400 border border-blue-500/30" : "bg-neutral-800/50 text-neutral-500 border border-white/5 hover:text-neutral-300"
onClick={() => { setTab("events"); setEventType("open-day"); }}
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
tab === "events" && eventType === "open-day" ? "bg-blue-500/15 text-blue-400 shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
}`}
>
Open Day
</button>
)}
</div>
{/* Class selector (optional for Занятие) */}
{tab === "classes" && classGroupOptions.length > 0 && (
<SearchSelect
options={classGroupOptions}
value={classGroup}
onChange={setClassGroup}
placeholder="Группа (необязательно)"
/>
)}
{/* MC selector */}
{tab === "events" && eventType === "master-class" && mcOptions.length > 0 && (
<select value={mcTitle} onChange={(e) => setMcTitle(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
<option value="" className="bg-neutral-900">Выберите мастер-класс</option>
{mcOptions.map((mc) => (
<option key={mc.title} value={mc.title} className="bg-neutral-900">
{mc.title}
</option>
))}
</select>
{tab === "events" && eventType === "master-class" && mcSelectOptions.length > 0 && (
<SearchSelect
options={mcSelectOptions}
value={mcTitle}
onChange={setMcTitle}
placeholder="Выберите мастер-класс"
/>
)}
{/* Open Day class selector */}
{tab === "events" && eventType === "open-day" && odClasses.length > 0 && (
<select value={odClassId} onChange={(e) => setOdClassId(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
<option value="" className="bg-neutral-900">Выберите занятие</option>
{odClasses.map((c) => (
<option key={c.id} value={c.id} className="bg-neutral-900">
{c.time} · {c.style} · {c.hall}
</option>
))}
</select>
{tab === "events" && eventType === "open-day" && odSelectOptions.length > 0 && (
<SearchSelect
options={odSelectOptions}
value={odClassId}
onChange={setOdClassId}
placeholder="Выберите занятие"
/>
)}
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Имя" className={inputClass} />
+22 -22
View File
@@ -47,17 +47,17 @@ export function DeleteBtn({ onClick, name }: { onClick: () => void; name?: strin
{confirming && createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirming(false)}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-xs rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button onClick={() => setConfirming(false)} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
<div className="relative w-full max-w-xs rounded-2xl border border-neutral-200 bg-white p-5 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]" onClick={(e) => e.stopPropagation()}>
<button onClick={() => setConfirming(false)} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white">
<X size={16} />
</button>
<h3 className="text-sm font-bold text-white">Удалить запись?</h3>
{name && <p className="mt-1 text-xs text-neutral-400">{name}</p>}
<p className="mt-2 text-xs text-neutral-500">Это действие нельзя отменить.</p>
<h3 className="text-sm font-bold text-neutral-900 dark:text-white">Удалить запись?</h3>
{name && <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{name}</p>}
<p className="mt-2 text-xs text-neutral-400 dark:text-neutral-500">Это действие нельзя отменить.</p>
<div className="mt-4 flex gap-2">
<button
onClick={() => setConfirming(false)}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 py-2 text-xs font-medium text-neutral-300 hover:bg-neutral-700 transition-colors"
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 py-2 text-xs font-medium text-neutral-700 hover:bg-neutral-200 transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
Отмена
</button>
@@ -80,17 +80,17 @@ export function ContactLinks({ phone, instagram, telegram }: { phone?: string; i
return (
<>
{phone && (
<a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
<a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-500 dark:text-emerald-400 dark:hover:text-emerald-300 text-xs">
<Phone size={10} />{phone}
</a>
)}
{instagram && (
<a href={`https://ig.me/m/${instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
<a href={`https://ig.me/m/${instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-600 hover:text-pink-500 dark:text-pink-400 dark:hover:text-pink-300 text-xs">
<Instagram size={10} />{instagram}
</a>
)}
{telegram && (
<a href={`https://t.me/${telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
<a href={`https://t.me/${telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 text-xs">
<Send size={10} />{telegram}
</a>
)}
@@ -109,7 +109,7 @@ export function FilterTabs({ filter, counts, total, onFilter }: {
<button
onClick={() => onFilter("all")}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-100 text-neutral-500 border border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
}`}
>
Все <span className="text-neutral-500 ml-1">{total}</span>
@@ -119,7 +119,7 @@ export function FilterTabs({ filter, counts, total, onFilter }: {
key={s.key}
onClick={() => onFilter(s.key)}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-100 text-neutral-500 border border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
}`}
>
{s.label}
@@ -147,27 +147,27 @@ export function StatusActions({ status, onStatus }: { status: BookingStatus; onS
);
return (
<div className="flex gap-1 ml-auto">
{status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")}
{status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")}
{status === "contacted" && (
<>
{actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")}
{actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20")}
{actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")}
{actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/30 hover:bg-red-500/20")}
</>
)}
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300")}
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-100 text-neutral-600 border border-neutral-300 hover:border-neutral-400 hover:text-neutral-800 dark:bg-neutral-800/50 dark:text-neutral-500 dark:border-transparent dark:hover:border-white/10 dark:hover:text-neutral-300")}
</div>
);
}
export function BookingCard({ status, children }: { status: BookingStatus; children: React.ReactNode }) {
export function BookingCard({ status, highlight, children }: { status: BookingStatus; highlight?: boolean; children: React.ReactNode }) {
return (
<div
className={`rounded-lg border p-3 transition-colors ${
status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
: status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
: status === "new" ? "border-gold/20 bg-gold/[0.03]"
: "border-white/10 bg-neutral-800/30"
}`}
className={`rounded-lg border p-3 transition-all duration-200 cursor-default ${
status === "declined" ? "border-red-500/20 bg-red-500/[0.04] opacity-50 hover:opacity-70 hover:border-red-500/30 dark:border-red-500/15 dark:bg-red-500/[0.02]"
: status === "confirmed" ? "border-emerald-500/20 bg-emerald-500/[0.04] hover:border-emerald-500/30 hover:bg-emerald-500/[0.08] dark:border-emerald-500/15 dark:bg-emerald-500/[0.02] dark:hover:bg-emerald-500/[0.05]"
: status === "new" ? "border-gold/30 bg-gold/[0.06] hover:border-gold/50 hover:bg-gold/[0.1] dark:border-gold/20 dark:bg-gold/[0.03] dark:hover:border-gold/40 dark:hover:bg-gold/[0.06]"
: "border-neutral-200 bg-neutral-50 hover:border-neutral-300 hover:bg-neutral-100 dark:border-white/10 dark:bg-neutral-800/30 dark:hover:border-white/20 dark:hover:bg-neutral-800/50"
}${highlight ? " ring-2 ring-gold/40 animate-[pulse_1s_ease-in-out_1]" : ""}`}
>
{children}
</div>
+50 -15
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { ChevronDown, ChevronRight, Archive } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingGroup, sortByStatus } from "./types";
@@ -32,8 +32,20 @@ export function GenericBookingsList<T extends BaseBooking>({
}: GenericBookingsListProps<T>) {
const [showArchived, setShowArchived] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [highlightId, setHighlightId] = useState<number | null>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const { showError } = useToast();
// Scroll to highlighted card and clear highlight after animation
useEffect(() => {
if (highlightId === null) return;
const timer = setTimeout(() => {
highlightRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, 50);
const clear = setTimeout(() => setHighlightId(null), 2000);
return () => { clearTimeout(timer); clearTimeout(clear); };
}, [highlightId]);
async function handleStatus(id: number, status: BookingStatus) {
if (status === "confirmed" && onConfirm) {
onConfirm(id);
@@ -41,7 +53,13 @@ export function GenericBookingsList<T extends BaseBooking>({
}
const prev = items.find((b) => b.id === id);
const prevStatus = prev?.status;
onItemsChange((list) => list.map((b) => b.id === id ? { ...b, status } : b));
// Move changed item to front so it appears first in its status group after sort
onItemsChange((list) => {
const item = list.find((b) => b.id === id);
if (!item) return list;
return [{ ...item, status }, ...list.filter((b) => b.id !== id)];
});
setHighlightId(id);
try {
const res = await adminFetch(endpoint, {
method: "PUT",
@@ -85,11 +103,13 @@ export function GenericBookingsList<T extends BaseBooking>({
}
function renderItem(item: T, isArchived: boolean) {
const isHighlighted = highlightId === item.id;
return (
<BookingCard key={item.id} status={item.status}>
<div key={item.id} ref={isHighlighted ? highlightRef : undefined}>
<BookingCard status={item.status} highlight={isHighlighted}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
<span className="font-medium text-white truncate max-w-[200px]">{item.name}</span>
<span className="font-medium text-neutral-900 dark:text-white truncate max-w-[200px]">{item.name}</span>
<ContactLinks phone={item.phone} instagram={item.instagram} telegram={item.telegram} />
{renderExtra?.(item)}
</div>
@@ -104,6 +124,7 @@ export function GenericBookingsList<T extends BaseBooking>({
</div>
<InlineNotes value={item.notes || ""} onSave={(notes) => handleNotes(item.id, notes)} />
</BookingCard>
</div>
);
}
@@ -123,42 +144,56 @@ export function GenericBookingsList<T extends BaseBooking>({
const groupCounts = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
for (const item of group.items) groupCounts[item.status] = (groupCounts[item.status] || 0) + 1;
return (
<div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-white/5 opacity-60" : "border-white/10"}`}>
<div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-neutral-100 opacity-60 dark:border-white/5" : "border-neutral-200 dark:border-white/10"}`}>
<button
onClick={() => setExpanded((p) => ({ ...p, [group.key]: !isOpen }))}
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors text-left ${group.isArchived ? "bg-neutral-900/50 hover:bg-neutral-800/50" : "bg-neutral-900 hover:bg-neutral-800/80"}`}
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors text-left ${group.isArchived ? "bg-neutral-100/50 hover:bg-neutral-200/50 dark:bg-neutral-900/50 dark:hover:bg-neutral-800/50" : "bg-neutral-50 hover:bg-neutral-200/80 dark:bg-neutral-900 dark:hover:bg-neutral-800/80"}`}
>
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
{group.sublabel && (
<span className={`text-xs font-medium shrink-0 ${group.isArchived ? "text-neutral-500" : "text-gold"}`}>{group.sublabel}</span>
)}
<span className={`font-medium text-sm truncate ${group.isArchived ? "text-neutral-400" : "text-white"}`}>{group.label}</span>
<span className={`font-medium text-sm truncate ${group.isArchived ? "text-neutral-500 dark:text-neutral-400" : "text-neutral-900 dark:text-white"}`}>{group.label}</span>
{group.dateBadge && (
<span className={`text-[10px] rounded-full px-2 py-0.5 shrink-0 ${
group.isArchived ? "text-neutral-600 bg-neutral-800 line-through" : "text-gold bg-gold/10"
group.isArchived ? "text-neutral-600 bg-neutral-800 line-through" : "text-amber-700 dark:text-gold bg-gold/10"
}`}>
{group.dateBadge}
</span>
)}
{group.isArchived && (
<span className="text-[10px] text-neutral-600 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">архив</span>
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 shrink-0 dark:text-neutral-600 dark:bg-neutral-800">архив</span>
)}
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{group.items.length} чел.</span>
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 shrink-0 dark:bg-neutral-800">{group.items.length} чел.</span>
{!group.isArchived && (
<div className="flex gap-2 ml-auto text-[10px]">
{groupCounts.new > 0 && <span className="text-gold">{groupCounts.new} новых</span>}
{groupCounts.contacted > 0 && <span className="text-blue-400">{groupCounts.contacted} связ.</span>}
{groupCounts.confirmed > 0 && <span className="text-emerald-400">{groupCounts.confirmed} подтв.</span>}
{groupCounts.new > 0 && <span className="text-amber-700 dark:text-gold">{groupCounts.new} новых</span>}
{groupCounts.contacted > 0 && <span className="text-blue-600 dark:text-blue-400">{groupCounts.contacted} связ.</span>}
{groupCounts.confirmed > 0 && <span className="text-emerald-600 dark:text-emerald-400">{groupCounts.confirmed} подтв.</span>}
</div>
)}
</button>
{isOpen && (
{isOpen && (() => {
const regular = group.items.filter((i) => !i.notes?.includes("Лист ожидания"));
const waiting = group.items.filter((i) => i.notes?.includes("Лист ожидания"));
return (
<div className="px-4 pb-3 pt-1 space-y-2">
{group.items.map((item) => renderItem(item, group.isArchived))}
{regular.map((item) => renderItem(item, group.isArchived))}
{waiting.length > 0 && (
<>
<div className="flex items-center gap-2 pt-1">
<div className="flex-1 h-px bg-amber-500/20" />
<span className="text-[10px] text-amber-400 shrink-0">лист ожидания</span>
<div className="flex-1 h-px bg-amber-500/20" />
</div>
{waiting.map((item) => renderItem(item, group.isArchived))}
</>
)}
</div>
);
})()}
</div>
);
}
return (
+3 -3
View File
@@ -44,10 +44,10 @@ export function InlineNotes({ value, onSave }: { value: string; onSave: (notes:
return (
<button
onClick={() => setEditing(true)}
className="mt-2 flex items-start gap-1.5 rounded-md bg-amber-500/[0.06] border border-amber-500/10 px-2.5 py-1.5 text-left transition-colors hover:bg-amber-500/10"
className="mt-2 inline-flex items-start gap-1.5 text-left transition-colors group"
>
<StickyNote size={11} className="shrink-0 mt-0.5 text-amber-500/60" />
<span className="text-[11px] text-amber-200/70 leading-relaxed whitespace-pre-wrap">{value}</span>
<StickyNote size={11} className="shrink-0 mt-0.5 text-neutral-500 group-hover:text-gold transition-colors" />
<span className="text-[11px] text-neutral-400 leading-relaxed whitespace-pre-wrap group-hover:text-white transition-colors">{value}</span>
</button>
);
}
@@ -11,11 +11,12 @@ interface McRegistration extends BaseBooking {
}
interface McSlot { date: string; startTime: string }
interface McItem { title: string; slots: McSlot[] }
interface McItem { title: string; slots: McSlot[]; location?: string }
export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFilter; onDataChange?: () => void }) {
const [regs, setRegs] = useState<McRegistration[]>([]);
const [mcDates, setMcDates] = useState<Record<string, string>>({});
const [mcLocations, setMcLocations] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -25,10 +26,12 @@ export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFi
]).then(([regData, mcData]: [McRegistration[], { items?: McItem[] }]) => {
setRegs(regData);
const dates: Record<string, string> = {};
const locations: Record<string, string> = {};
const mcItems = mcData.items || [];
for (const mc of mcItems) {
const earliestSlot = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
if (earliestSlot) dates[mc.title] = earliestSlot;
if (mc.location) locations[mc.title] = mc.location;
}
const regTitles = new Set(regData.map((r) => r.masterClassTitle));
for (const regTitle of regTitles) {
@@ -43,6 +46,7 @@ export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFi
}
}
setMcDates(dates);
setMcLocations(locations);
}).catch(() => {}).finally(() => setLoading(false));
}, []);
@@ -59,13 +63,13 @@ export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFi
const isArchived = !date || date < today;
return {
key: title,
label: title,
label: mcLocations[title] ? `${title} · ${mcLocations[title]}` : title,
dateBadge: date ? new Date(date + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined,
items,
isArchived,
};
});
}, [regs, mcDates, today]);
}, [regs, mcDates, mcLocations, today]);
if (loading) return <LoadingSpinner />;
+14 -5
View File
@@ -17,7 +17,7 @@ interface OpenDayBooking extends BaseBooking {
interface EventInfo { id: number; date: string; title?: string }
export function OpenDayBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onDataChange?: () => void }) {
export function OpenDayBookingsTab({ filter, hallFilter = "all", onDataChange }: { filter: BookingFilter; hallFilter?: string; onDataChange?: () => void }) {
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
const [events, setEvents] = useState<EventInfo[]>([]);
const [loading, setLoading] = useState(true);
@@ -47,9 +47,13 @@ export function OpenDayBookingsTab({ filter, onDataChange }: { filter: BookingFi
return map;
}, [events]);
const filteredBookings = useMemo(() =>
hallFilter === "all" ? bookings : bookings.filter((b) => b.classHall === hallFilter),
[bookings, hallFilter]);
const groups = useMemo((): BookingGroup<OpenDayBooking>[] => {
const map: Record<string, { hall: string; time: string; style: string; trainer: string; items: OpenDayBooking[]; eventId: number }> = {};
for (const b of bookings) {
for (const b of filteredBookings) {
const key = `${b.eventId}|${b.classHall}|${b.classTime}|${b.classStyle}`;
if (!map[key]) map[key] = { hall: b.classHall || "—", time: b.classTime || "—", style: b.classStyle || "—", trainer: b.classTrainer || "—", items: [], eventId: b.eventId };
map[key].items.push(b);
@@ -64,25 +68,30 @@ export function OpenDayBookingsTab({ filter, onDataChange }: { filter: BookingFi
const isArchived = eventDate ? eventDate < today : false;
return {
key,
label: g.style,
label: `${g.style} · ${g.hall}`,
sublabel: g.time,
dateBadge: isArchived && eventDate ? new Date(eventDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined,
items: g.items,
isArchived,
};
});
}, [bookings, eventDateMap, today]);
}, [filteredBookings, eventDateMap, today]);
if (loading) return <LoadingSpinner />;
return (
<GenericBookingsList<OpenDayBooking>
items={bookings}
items={filteredBookings}
endpoint="/api/admin/open-day/bookings"
filter={filter}
onItemsChange={setBookings}
onDataChange={onDataChange}
groups={groups}
renderExtra={(b) => (
<>
{b.classHall && <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{b.classHall}</span>}
</>
)}
/>
);
}
+2 -2
View File
@@ -43,10 +43,10 @@ export function SearchBar({
value={query}
onChange={(e) => handleChange(e.target.value)}
placeholder="Поиск по имени или телефону..."
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] py-2 pl-9 pr-8 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40"
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 py-2 pl-9 pr-8 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/40 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500"
/>
{query && (
<button onClick={clear} className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white">
<button onClick={clear} className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-900 dark:hover:text-white">
<X size={14} />
</button>
)}
+2 -68
View File
@@ -1,68 +1,2 @@
"use client";
import { useState, useEffect, useCallback, createContext, useContext } from "react";
import { X, AlertCircle, CheckCircle2 } from "lucide-react";
interface ToastItem {
id: number;
message: string;
type: "error" | "success";
}
interface ToastContextValue {
showError: (message: string) => void;
showSuccess: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue>({
showError: () => {},
showSuccess: () => {},
});
export function useToast() {
return useContext(ToastContext);
}
let nextId = 0;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const addToast = useCallback((message: string, type: "error" | "success") => {
const id = ++nextId;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
}, []);
const showError = useCallback((message: string) => addToast(message, "error"), [addToast]);
const showSuccess = useCallback((message: string) => addToast(message, "success"), [addToast]);
return (
<ToastContext.Provider value={{ showError, showSuccess }}>
{children}
{toasts.length > 0 && (
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
{toasts.map((t) => (
<div
key={t.id}
className={`flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm shadow-lg animate-in slide-in-from-right ${
t.type === "error"
? "bg-red-950/90 border-red-500/30 text-red-200"
: "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
}`}
>
{t.type === "error" ? <AlertCircle size={14} className="shrink-0" /> : <CheckCircle2 size={14} className="shrink-0" />}
<span className="flex-1">{t.message}</span>
<button
onClick={() => setToasts((prev) => prev.filter((tt) => tt.id !== t.id))}
className="shrink-0 text-neutral-400 hover:text-white"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
</ToastContext.Provider>
);
}
// Re-export from shared location
export { ToastProvider, useToast } from "../_components/Toast";
+126 -62
View File
@@ -28,6 +28,7 @@ interface GroupBooking {
status: BookingStatus;
confirmedDate?: string;
confirmedGroup?: string;
confirmedHall?: string;
confirmedComment?: string;
notes?: string;
createdAt: string;
@@ -53,7 +54,7 @@ function ConfirmModal({
existingDate?: string;
existingGroup?: string;
allClasses: ScheduleClassInfo[];
onConfirm: (data: { group: string; date: string; comment?: string }) => void;
onConfirm: (data: { group: string; hall?: string; date: string; comment?: string }) => void;
onClose: () => void;
}) {
const [hall, setHall] = useState("");
@@ -144,9 +145,9 @@ function ConfirmModal({
const handleSubmit = useCallback(() => {
if (canSubmit) {
const groupLabel = groups.find((g) => g.value === group)?.label || group;
onConfirm({ group: groupLabel, date, comment: comment.trim() || undefined });
onConfirm({ group: groupLabel, hall: hall || undefined, date, comment: comment.trim() || undefined });
}
}, [canSubmit, group, date, comment, groups, onConfirm]);
}, [canSubmit, group, hall, date, comment, groups, onConfirm]);
useEffect(() => {
if (!open) return;
@@ -160,43 +161,43 @@ function ConfirmModal({
if (!open) return null;
const selectClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 [color-scheme:dark] disabled:opacity-30 disabled:cursor-not-allowed";
const selectClass = "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 [color-scheme:light] disabled:opacity-30 disabled:cursor-not-allowed dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:[color-scheme:dark]";
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" onClick={onClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose} aria-label="Закрыть" className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
<div className="relative w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose} aria-label="Закрыть" className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white">
<X size={16} />
</button>
<h3 className="text-base font-bold text-white">Подтвердить запись</h3>
<p className="mt-1 text-xs text-neutral-400">{bookingName}</p>
<h3 className="text-base font-bold text-neutral-900 dark:text-white">Подтвердить запись</h3>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{bookingName}</p>
<div className="mt-4 space-y-3">
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Зал</label>
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Зал</label>
<select value={hall} onChange={(e) => setHall(e.target.value)} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите зал</option>
{halls.map((h) => <option key={h} value={h} className="bg-neutral-900">{h}</option>)}
<option value="" className="bg-white dark:bg-neutral-900">Выберите зал</option>
{halls.map((h) => <option key={h} value={h} className="bg-white dark:bg-neutral-900">{h}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Тренер</label>
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Тренер</label>
<select value={trainer} onChange={(e) => setTrainer(e.target.value)} disabled={!hall} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите тренера</option>
{trainers.map((t) => <option key={t} value={t} className="bg-neutral-900">{t}</option>)}
<option value="" className="bg-white dark:bg-neutral-900">Выберите тренера</option>
{trainers.map((t) => <option key={t} value={t} className="bg-white dark:bg-neutral-900">{t}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Группа</label>
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Группа</label>
<select value={group} onChange={(e) => setGroup(e.target.value)} disabled={!trainer} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите группу</option>
{groups.map((g) => <option key={g.value} value={g.value} className="bg-neutral-900">{g.label}</option>)}
<option value="" className="bg-white dark:bg-neutral-900">Выберите группу</option>
{groups.map((g) => <option key={g.value} value={g.value} className="bg-white dark:bg-neutral-900">{g.label}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Дата занятия</label>
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Дата занятия</label>
<input
type="date"
value={date}
@@ -211,14 +212,14 @@ function ConfirmModal({
)}
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
<input
type="text"
value={comment}
disabled={!group}
onChange={(e) => setComment(e.target.value)}
placeholder="Первое занятие, пробный"
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40 disabled:opacity-30 disabled:cursor-not-allowed"
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/40 disabled:opacity-30 disabled:cursor-not-allowed dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500"
/>
</div>
</div>
@@ -272,7 +273,7 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
const confirmingBooking = bookings.find((b) => b.id === confirmingId);
async function handleConfirm(data: { group: string; date: string; comment?: string }) {
async function handleConfirm(data: { group: string; hall?: string; date: string; comment?: string }) {
if (!confirmingId) return;
const existing = bookings.find((b) => b.id === confirmingId);
const notes = data.comment
@@ -280,13 +281,14 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
: existing?.notes;
setBookings((prev) => prev.map((b) => b.id === confirmingId ? {
...b, status: "confirmed" as BookingStatus,
confirmedDate: data.date, confirmedGroup: data.group, notes,
confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes,
} : b));
try {
await Promise.all([
adminFetch("/api/admin/group-bookings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, date: data.date } }),
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }),
}),
data.comment ? adminFetch("/api/admin/group-bookings", {
method: "PUT",
@@ -294,6 +296,10 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
}) : Promise.resolve(),
]);
} catch {
// Revert optimistic update on failure
setBookings((prev) => prev.map((b) => b.id === confirmingId ? { ...b, ...existing } : b));
}
setConfirmingId(null);
onDataChange?.();
}
@@ -312,7 +318,8 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
onConfirm={(id) => setConfirmingId(id)}
renderExtra={(b) => (
<>
{b.groupInfo && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>}
{b.groupInfo && <span className="text-xs text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:text-neutral-400 dark:bg-neutral-800">{b.groupInfo}</span>}
{b.confirmedHall && <span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{b.confirmedHall}</span>}
{(b.confirmedGroup || b.confirmedDate) && (
<button
onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }}
@@ -354,6 +361,7 @@ interface ReminderItem {
telegram?: string;
reminderStatus?: string;
eventLabel: string;
eventHall?: string;
eventDate: string;
}
@@ -463,11 +471,11 @@ function RemindersTab() {
: currentStatus === "coming" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
: currentStatus === "cancelled" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
: currentStatus === "pending" ? "border-amber-500/15 bg-amber-500/[0.02]"
: "border-white/5 bg-neutral-800/30"
: "border-neutral-200 bg-neutral-100/30 dark:border-white/5 dark:bg-neutral-800/30"
}`}
>
<div className="flex items-center gap-2 flex-wrap text-sm">
<span className="font-medium text-white">{item.name}</span>
<span className="font-medium text-neutral-900 dark:text-white">{item.name}</span>
{item.phone && (
<a href={`tel:${item.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
<Phone size={10} />{item.phone}
@@ -534,11 +542,11 @@ function RemindersTab() {
const TypeIcon = typeConf.icon;
const egStats = countByStatus(eg.items);
return (
<div key={eg.label} className="rounded-xl border border-white/10 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-900">
<div key={eg.label} className="rounded-xl border border-neutral-200 overflow-hidden dark:border-white/10">
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-50 dark:bg-neutral-900">
<TypeIcon size={13} className={typeConf.color} />
<span className="text-sm font-medium text-white">{eg.label}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{eg.items.length} чел.</span>
<span className="text-sm font-medium text-neutral-900 dark:text-white">{eg.label}{eg.items[0]?.eventHall ? ` · ${eg.items[0].eventHall}` : ""}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{eg.items.length} чел.</span>
<div className="flex gap-2 ml-auto text-[10px]">
{egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>}
{egStats.cancelled > 0 && <span className="text-red-400">{egStats.cancelled} не придёт</span>}
@@ -594,10 +602,12 @@ function countByStatus(items: { status: string }[]): TabCounts {
}
function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
function DashboardSummary({ refreshTrigger, onNavigate, onFilter, activeTab, activeFilter }: {
refreshTrigger: number;
onNavigate: (tab: Tab) => void;
onFilter: (f: BookingFilter) => void;
activeTab: Tab;
activeFilter: BookingFilter;
}) {
const [counts, setCounts] = useState<DashboardCounts | null>(null);
@@ -662,15 +672,15 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
if (c.tab === "reminders") {
const total = counts.remindersToday + counts.remindersTomorrow;
if (total === 0) return (
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40">
<div key={c.tab} className="rounded-xl border border-neutral-100 bg-neutral-50 p-3 opacity-40 dark:border-white/5 dark:bg-neutral-900/50">
<p className="text-xs text-neutral-500">{c.label}</p>
<p className="text-lg font-bold text-neutral-600 mt-1"></p>
<p className="text-lg font-bold text-neutral-400 mt-1 dark:text-neutral-600"></p>
</div>
);
return (
<button key={c.tab} onClick={() => onNavigate(c.tab)}
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}>
<p className="text-xs text-neutral-400">{c.label}</p>
className={`rounded-xl border ${c.color} bg-neutral-50 p-3 text-left transition-all hover:bg-neutral-100 hover:scale-[1.02] dark:bg-neutral-900 dark:hover:bg-neutral-800/80`}>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{c.label}</p>
<div className="flex items-baseline gap-2 mt-1 flex-wrap">
{counts.remindersNotAsked > 0 && (
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
@@ -708,20 +718,25 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
const tc = c.counts!;
const total = tc.new + tc.contacted + tc.confirmed + tc.declined;
if (total === 0) return (
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40">
<div key={c.tab} className="rounded-xl border border-neutral-100 bg-neutral-50 p-3 opacity-40 dark:border-white/5 dark:bg-neutral-900/50">
<p className="text-xs text-neutral-500">{c.label}</p>
<p className="text-lg font-bold text-neutral-600 mt-1"></p>
<p className="text-lg font-bold text-neutral-400 mt-1 dark:text-neutral-600"></p>
</div>
);
const isActiveCard = activeTab === c.tab;
const hl = (status: BookingFilter) =>
isActiveCard && activeFilter === status
? "rounded-md bg-neutral-200 px-1.5 -mx-1.5 py-0.5 -my-0.5 ring-1 ring-neutral-300 dark:bg-white/10 dark:ring-white/20"
: "";
return (
<button key={c.tab} onClick={() => { onNavigate(c.tab); onFilter("all"); }}
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}>
<p className="text-xs text-neutral-400">{c.label}</p>
className={`rounded-xl border ${c.color} bg-neutral-50 p-3 text-left transition-all hover:bg-neutral-100 hover:scale-[1.02] dark:bg-neutral-900 dark:hover:bg-neutral-800/80`}>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{c.label}</p>
<div className="flex items-baseline gap-2 mt-1 flex-wrap">
{tc.new > 0 && (
<>
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("new"); }}>
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("new")}`}
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "new" && isActiveCard ? "all" : "new"); }}>
<span className="text-lg font-bold text-gold">{tc.new}</span>
<span className="text-[10px] text-neutral-500">новых</span>
</span>
@@ -730,8 +745,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
{tc.contacted > 0 && (
<>
{tc.new > 0 && <span className="text-neutral-700">·</span>}
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("contacted"); }}>
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("contacted")}`}
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "contacted" && isActiveCard ? "all" : "contacted"); }}>
<span className="text-sm font-medium text-blue-400">{tc.contacted}</span>
<span className="text-[10px] text-neutral-500">в работе</span>
</span>
@@ -740,8 +755,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
{tc.confirmed > 0 && (
<>
{(tc.new > 0 || tc.contacted > 0) && <span className="text-neutral-700">·</span>}
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("confirmed"); }}>
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("confirmed")}`}
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "confirmed" && isActiveCard ? "all" : "confirmed"); }}>
<span className="text-sm font-medium text-emerald-400">{tc.confirmed}</span>
<span className="text-[10px] text-neutral-500">подтв.</span>
</span>
@@ -750,8 +765,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
{tc.declined > 0 && (
<>
{(tc.new > 0 || tc.contacted > 0 || tc.confirmed > 0) && <span className="text-neutral-700">·</span>}
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("declined"); }}>
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("declined")}`}
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "declined" && isActiveCard ? "all" : "declined"); }}>
<span className="text-sm font-medium text-red-400">{tc.declined}</span>
<span className="text-[10px] text-neutral-500">отказ</span>
</span>
@@ -785,12 +800,25 @@ function BookingsPageInner() {
const [addOpen, setAddOpen] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
const [statusFilter, setStatusFilter] = useState<BookingFilter>("all");
const [hallFilter, setHallFilter] = useState("all");
const [halls, setHalls] = useState<string[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const [dashboardKey, setDashboardKey] = useState(0);
const refreshDashboard = useCallback(() => setDashboardKey((k) => k + 1), []);
const lastTotalRef = useRef<number | null>(null);
const { showError } = useToast();
// Fetch available halls from schedule
useEffect(() => {
adminFetch("/api/admin/sections/schedule")
.then((r) => r.json())
.then((data: { locations?: { name: string }[] }) => {
const names = data.locations?.map((l) => l.name).filter(Boolean) ?? [];
setHalls([...new Set(names)]);
})
.catch(() => {});
}, []);
// Poll for new bookings, auto-refresh silently
useEffect(() => {
const id = setInterval(() => {
@@ -799,7 +827,7 @@ function BookingsPageInner() {
.then((r) => r.json())
.then((data: { total: number }) => {
if (lastTotalRef.current !== null && data.total !== lastTotalRef.current) {
setRefreshKey((k) => k + 1);
refreshDashboard();
}
lastTotalRef.current = data.total;
})
@@ -863,6 +891,31 @@ function BookingsPageInner() {
/>
</div>
{/* Hall filter */}
{halls.length > 1 && (
<div className="mt-3 flex gap-2 flex-wrap">
<button
onClick={() => setHallFilter("all")}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
hallFilter === "all" ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-neutral-900 border border-transparent dark:hover:text-white"
}`}
>
Все залы
</button>
{halls.map((hall) => (
<button
key={hall}
onClick={() => setHallFilter(hallFilter === hall ? "all" : hall)}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
hallFilter === hall ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-neutral-900 border border-transparent dark:hover:text-white"
}`}
>
{hall}
</button>
))}
</div>
)}
{searchResults ? (
/* #5: Actionable search results — filtered by status */
(() => {
@@ -876,10 +929,10 @@ function BookingsPageInner() {
<BookingCard key={`${r.type}-${r.id}`} status={r.status as BookingStatus}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{TYPE_LABELS[r.type] || r.type}</span>
<span className="font-medium text-white">{r.name}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{TYPE_LABELS[r.type] || r.type}</span>
<span className="font-medium text-neutral-900 dark:text-white">{r.name}</span>
<ContactLinks phone={r.phone} instagram={r.instagram} telegram={r.telegram} />
{r.groupLabel && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{r.groupLabel}</span>}
{r.groupLabel && <span className="text-xs text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:text-neutral-400 dark:bg-neutral-800">{r.groupLabel}</span>}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-neutral-600 text-xs">{fmtDate(r.createdAt)}</span>
@@ -900,16 +953,27 @@ function BookingsPageInner() {
) : (
<>
{/* Dashboard — what needs attention */}
<DashboardSummary refreshTrigger={dashboardKey + refreshKey} onNavigate={setTab} onFilter={setStatusFilter} />
<DashboardSummary refreshTrigger={dashboardKey + refreshKey} onNavigate={setTab} onFilter={setStatusFilter} activeTab={tab} activeFilter={statusFilter} />
{/* Tabs */}
<div className="mt-5 flex border-b border-white/10">
{/* Tabs — select on mobile, tabs on desktop */}
<div className="mt-5 sm:hidden">
<select
value={tab}
onChange={(e) => setTab(e.target.value as Tab)}
className="w-full rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-900 outline-none focus:border-gold/40 transition-colors dark:border-white/10 dark:bg-neutral-900 dark:text-white dark:[color-scheme:dark]"
>
{TABS.map((t) => (
<option key={t.key} value={t.key}>{t.label}</option>
))}
</select>
</div>
<div className="mt-5 hidden sm:flex border-b border-neutral-200 dark:border-white/10">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
tab === t.key ? "text-gold" : "text-neutral-400 hover:text-white"
className={`shrink-0 px-4 py-2.5 text-sm font-medium transition-colors relative whitespace-nowrap ${
tab === t.key ? "text-gold" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
}`}
>
{t.label}
@@ -921,11 +985,11 @@ function BookingsPageInner() {
</div>
{/* Tab content */}
<div className="mt-4" key={`tab-${refreshKey}`}>
{tab === "reminders" && <RemindersTab />}
{tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
<div className="mt-4">
{tab === "reminders" && <RemindersTab key={refreshKey} />}
{tab === "classes" && <GroupBookingsTab key={refreshKey} filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "master-classes" && <McRegistrationsTab key={refreshKey} filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "open-day" && <OpenDayBookingsTab key={refreshKey} filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />}
</div>
</>
)}
@@ -933,7 +997,7 @@ function BookingsPageInner() {
<AddBookingModal
open={addOpen}
onClose={() => setAddOpen(false)}
onAdded={() => setRefreshKey((k) => k + 1)}
onAdded={() => { setStatusFilter("all"); setRefreshKey((k) => k + 1); refreshDashboard(); }}
/>
</div>
);
+21 -10
View File
@@ -1,3 +1,5 @@
import { SHORT_DAYS } from "@/lib/formatting";
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
export type BookingFilter = "all" | BookingStatus;
@@ -12,20 +14,26 @@ export interface BaseBooking {
createdAt: string;
}
export const SHORT_DAYS: Record<string, string> = {
"Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ",
"Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС",
};
export { SHORT_DAYS };
export const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [
{ key: "new", label: "Новая", color: "text-gold", bg: "bg-gold/10", border: "border-gold/30" },
{ key: "contacted", label: "Связались", color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
{ key: "confirmed", label: "Подтверждено", color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" },
{ key: "declined", label: "Отказ", color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
{ key: "new", label: "Новая", color: "text-amber-700 dark:text-gold", bg: "bg-gold/10", border: "border-gold/30" },
{ key: "contacted", label: "Связались", color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
{ key: "confirmed", label: "Подтверждено", color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" },
{ key: "declined", label: "Отказ", color: "text-red-600 dark:text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
];
export function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString("ru-RU");
const d = new Date(iso);
const now = new Date();
const sameYear = d.getFullYear() === now.getFullYear();
const date = d.toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
...(sameYear ? {} : { year: "numeric" }),
});
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
return `${date}, ${time}`;
}
export function countStatuses(items: { status: string }[]): Record<string, number> {
@@ -36,7 +44,10 @@ export function countStatuses(items: { status: string }[]): Record<string, numbe
export function sortByStatus<T extends { status: string }>(items: T[]): T[] {
const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 };
return [...items].sort((a, b) => (order[a.status] ?? 0) - (order[b.status] ?? 0));
const UNKNOWN_STATUS_ORDER = 4;
return [...items].sort((a, b) =>
(order[a.status] ?? UNKNOWN_STATUS_ORDER) - (order[b.status] ?? UNKNOWN_STATUS_ORDER)
);
}
export interface BookingGroup<T extends BaseBooking> {
+80 -18
View File
@@ -2,23 +2,66 @@
import { useState, useRef, useEffect, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { InputField, TextareaField, RichTextarea } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { icons, type LucideIcon } from "lucide-react";
import { ImageCropField } from "../_components/ImageCropField";
import {
icons, type LucideIcon,
Flame, Heart, HeartPulse, Star, Sparkles, Music, Zap, Crown,
Dumbbell, Wind, Moon, Sun, Ribbon, Gem, Feather, CircleDot,
Activity, Drama, PersonStanding, Footprints, PartyPopper, Flower2,
Waves, Eye, Orbit, Brush, Palette, HandMetal, Theater,
} from "lucide-react";
// Curated icons for dance school
const CURATED_ICONS: { key: string; Icon: LucideIcon; label: string }[] = [
{ key: "flame", Icon: Flame, label: "Flame" },
{ key: "heart", Icon: Heart, label: "Heart" },
{ key: "heart-pulse", Icon: HeartPulse, label: "HeartPulse" },
{ key: "star", Icon: Star, label: "Star" },
{ key: "sparkles", Icon: Sparkles, label: "Sparkles" },
{ key: "music", Icon: Music, label: "Music" },
{ key: "zap", Icon: Zap, label: "Zap" },
{ key: "crown", Icon: Crown, label: "Crown" },
{ key: "dumbbell", Icon: Dumbbell, label: "Dumbbell" },
{ key: "wind", Icon: Wind, label: "Wind" },
{ key: "moon", Icon: Moon, label: "Moon" },
{ key: "sun", Icon: Sun, label: "Sun" },
{ key: "ribbon", Icon: Ribbon, label: "Ribbon" },
{ key: "gem", Icon: Gem, label: "Gem" },
{ key: "feather", Icon: Feather, label: "Feather" },
{ key: "circle-dot", Icon: CircleDot, label: "CircleDot" },
{ key: "activity", Icon: Activity, label: "Activity" },
{ key: "drama", Icon: Drama, label: "Drama" },
{ key: "person-standing", Icon: PersonStanding, label: "PersonStanding" },
{ key: "footprints", Icon: Footprints, label: "Footprints" },
{ key: "party-popper", Icon: PartyPopper, label: "PartyPopper" },
{ key: "flower-2", Icon: Flower2, label: "Flower" },
{ key: "waves", Icon: Waves, label: "Waves" },
{ key: "eye", Icon: Eye, label: "Eye" },
{ key: "orbit", Icon: Orbit, label: "Orbit" },
{ key: "brush", Icon: Brush, label: "Brush" },
{ key: "palette", Icon: Palette, label: "Palette" },
{ key: "hand-metal", Icon: HandMetal, label: "HandMetal" },
{ key: "theater", Icon: Theater, label: "Theater" },
];
// PascalCase "HeartPulse" → kebab "heart-pulse"
function toKebab(name: string) {
return name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
}
// All icons as { key: kebab-name, Icon: component, label: PascalCase }
// Full icon list for search fallback
const ALL_ICONS = Object.entries(icons).map(([name, Icon]) => ({
key: toKebab(name),
Icon: Icon as LucideIcon,
label: name,
}));
const ICON_BY_KEY = Object.fromEntries(ALL_ICONS.map((i) => [i.key, i]));
const ICON_BY_KEY = Object.fromEntries([
...CURATED_ICONS.map((i) => [i.key, i]),
...ALL_ICONS.map((i) => [i.key, i]),
]);
function IconPicker({
value,
@@ -46,9 +89,12 @@ function IconPicker({
}, [open]);
const filtered = useMemo(() => {
if (!search) return ALL_ICONS.slice(0, 60);
if (!search) return CURATED_ICONS;
const q = search.toLowerCase();
return ALL_ICONS.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 60);
// Search curated first, then all icons
const curated = CURATED_ICONS.filter((i) => i.label.toLowerCase().includes(q));
const rest = ALL_ICONS.filter((i) => i.label.toLowerCase().includes(q) && !curated.some((c) => c.key === i.key));
return [...curated, ...rest].slice(0, 40);
}, [search]);
const SelectedIcon = selected?.Icon;
@@ -63,8 +109,8 @@ function IconPicker({
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}}
className={`w-full flex items-center gap-2.5 rounded-lg border bg-neutral-800 px-4 py-2.5 text-left text-white outline-none transition-colors ${
open ? "border-gold" : "border-white/10"
className={`w-full flex items-center gap-2.5 rounded-lg border bg-neutral-100 px-4 py-2.5 text-left text-neutral-900 outline-none transition-colors dark:bg-neutral-800 dark:text-white ${
open ? "border-gold" : "border-neutral-200 dark:border-white/10"
}`}
>
{SelectedIcon ? (
@@ -72,21 +118,21 @@ function IconPicker({
<SelectedIcon size={16} />
</span>
) : (
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-white/10 text-neutral-500">?</span>
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-neutral-200 text-neutral-500 dark:bg-white/10">?</span>
)}
<span className="text-sm">{selected?.label || value}</span>
</button>
{open && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
<div className="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/10 dark:bg-neutral-800">
<div className="p-2 pb-0">
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск иконки... (flame, heart, star...)"
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
placeholder="Поиск..."
className="w-full rounded-md border border-neutral-200 bg-neutral-100 px-3 py-1.5 text-sm text-neutral-900 outline-none focus:border-gold/50 placeholder:text-neutral-400 dark:border-white/10 dark:bg-neutral-900 dark:text-white dark:placeholder:text-neutral-600"
/>
</div>
<div className="p-2 max-h-56 overflow-y-auto">
@@ -107,7 +153,7 @@ function IconPicker({
className={`flex flex-col items-center gap-0.5 rounded-lg p-2 transition-colors ${
key === value
? "bg-gold/20 text-gold-light"
: "text-neutral-400 hover:bg-white/5 hover:text-white"
: "text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white"
}`}
>
<Icon size={20} />
@@ -150,13 +196,16 @@ interface ClassesData {
icon: string;
detailedDescription?: string;
images?: string[];
imageFocalX?: number;
imageFocalY?: number;
imageZoom?: number;
color?: string;
}[];
}
export default function ClassesEditorPage() {
return (
<SectionEditor<ClassesData> sectionKey="classes" title="Направления">
<SectionEditor<ClassesData> sectionKey="classes" title="Направления" defaultData={{ items: [] }}>
{(data, update) => (
<>
<InputField
@@ -188,18 +237,21 @@ export default function ClassesEditorPage() {
</label>
<div className="flex flex-wrap gap-1.5">
{COLOR_SWATCHES.map((c) => {
const isUsed = data.items.some(
const isSelected = item.color === c.value;
const isUsed = !isSelected && data.items.some(
(other) => other !== item && other.color === c.value
);
if (isUsed) return null;
return (
<button
key={c.value}
type="button"
disabled={isUsed}
onClick={() => updateItem({ ...item, color: c.value })}
className={`h-6 w-6 rounded-full ${c.bg} transition-all ${
item.color === c.value
isSelected
? "ring-2 ring-white ring-offset-1 ring-offset-neutral-900 scale-110"
: isUsed
? "opacity-15 cursor-not-allowed"
: "opacity-50 hover:opacity-100"
}`}
/>
@@ -207,13 +259,21 @@ export default function ClassesEditorPage() {
})}
</div>
</div>
<ImageCropField
image={item.images?.[0] || ""}
focalX={item.imageFocalX ?? 50}
focalY={item.imageFocalY ?? 50}
zoom={item.imageZoom ?? 1}
folder="classes"
onChange={(d) => updateItem({ ...item, images: d.image ? [d.image] : [], imageFocalX: d.focalX, imageFocalY: d.focalY, imageZoom: d.zoom })}
/>
<TextareaField
label="Краткое описание"
value={item.description}
onChange={(v) => updateItem({ ...item, description: v })}
rows={2}
/>
<TextareaField
<RichTextarea
label="Подробное описание"
value={item.detailedDescription || ""}
onChange={(v) =>
@@ -231,6 +291,8 @@ export default function ClassesEditorPage() {
images: [],
})}
addLabel="Добавить направление"
collapsible
getItemTitle={(item) => item.name || "Без названия"}
/>
</>
)}
+220 -32
View File
@@ -1,54 +1,242 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { Plus, X, AlertCircle, Check, Loader2 } from "lucide-react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { InputField } from "../_components/FormField";
import { CollapsibleSection } from "../_components/CollapsibleSection";
import { adminFetch } from "@/lib/csrf";
import type { ContactInfo } from "@/types/content";
// --- Phone input with mask ---
function PhoneField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
function formatPhone(raw: string): string {
const digits = raw.replace(/\D/g, "").slice(0, 12);
if (digits.length === 0) return "+375 ";
let result = "+";
for (let i = 0; i < digits.length; i++) {
if (i === 3) result += " (";
if (i === 5) result += ") ";
if (i === 8) result += "-";
if (i === 10) result += "-";
result += digits[i];
}
return result;
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const formatted = formatPhone(e.target.value);
onChange(formatted);
}
const digits = (value ?? "").replace(/\D/g, "");
const isComplete = digits.length === 12;
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Телефон</label>
<div className="relative">
<input
type="tel"
value={value ?? ""}
onChange={handleChange}
placeholder="+375 (XX) XXX-XX-XX"
className={`w-full rounded-lg border bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 ${
value && !isComplete ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
}`}
/>
{isComplete && (
<Check size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400" />
)}
</div>
{value && !isComplete && (
<p className="mt-1 text-xs text-red-400">Формат: +375 (XX) XXX-XX-XX данные не сохранятся</p>
)}
</div>
);
}
// --- Instagram field like team page (username with @ prefix + validation) ---
function InstagramField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [status, setStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Extract username from URL or @-prefixed input
function extractUsername(raw: string): string {
if (!raw) return "";
return raw
.replace(/^https?:\/\/(www\.)?instagram\.com\//, "")
.replace(/\/$/, "")
.replace(/^@/, "");
}
const validateUsername = useCallback((username: string) => {
if (timerRef.current) clearTimeout(timerRef.current);
if (!username) { setStatus("idle"); return; }
setStatus("checking");
timerRef.current = setTimeout(async () => {
try {
const res = await adminFetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`);
const result = await res.json();
setStatus(result.valid ? "valid" : "invalid");
} catch {
setStatus("idle");
}
}, 800);
}, []);
// On mount, if value exists, mark as valid (trusted existing data)
const initializedRef = useRef(false);
if (value && !initializedRef.current) {
initializedRef.current = true;
if (status === "idle") setStatus("valid");
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Instagram</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-500 text-sm select-none">@</span>
<input
type="text"
value={value ?? ""}
onChange={(e) => {
const username = extractUsername(e.target.value);
onChange(username);
validateUsername(username);
}}
placeholder="blackheartdancehouse"
className={`w-full rounded-lg border bg-neutral-100 pl-8 pr-10 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 ${
status === "invalid"
? "border-red-500 focus:border-red-500"
: status === "valid"
? "border-green-500/50 focus:border-green-500"
: "border-neutral-200 focus:border-gold dark:border-white/10"
}`}
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2">
{status === "checking" && <Loader2 size={14} className="animate-spin text-neutral-400" />}
{status === "valid" && <Check size={14} className="text-green-400" />}
{status === "invalid" && <AlertCircle size={14} className="text-red-400" />}
</span>
</div>
{status === "invalid" && (
<p className="mt-1 text-xs text-red-400">Аккаунт не найден</p>
)}
{value && status !== "invalid" && status !== "checking" && (
<a
href={`https://instagram.com/${value}`}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-block text-xs text-neutral-500 hover:text-gold transition-colors"
>
instagram.com/{value}
</a>
)}
</div>
);
}
// --- Compact address list ---
function AddressList({ items, onChange }: { items: string[]; onChange: (items: string[]) => void }) {
const [draft, setDraft] = useState("");
function add() {
const val = draft.trim();
if (!val) return;
onChange([...items, val]);
setDraft("");
}
function remove(index: number) {
onChange(items.filter((_, i) => i !== index));
}
function update(index: number, value: string) {
onChange(items.map((item, i) => (i === index ? value : item)));
}
return (
<div className="space-y-2">
{items.map((addr, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
value={addr}
onChange={(e) => update(i, e.target.value)}
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
/>
<button
type="button"
onClick={() => remove(i)}
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
</button>
</div>
))}
<div className="flex items-center gap-2">
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
onBlur={add}
placeholder="Добавить адрес..."
className="flex-1 rounded-lg border border-dashed border-neutral-300 bg-neutral-100/50 px-4 py-2.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/50 transition-colors dark:border-white/15 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-500"
/>
<button
type="button"
onClick={add}
disabled={!draft.trim()}
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-gold transition-colors disabled:opacity-30"
>
<Plus size={14} />
</button>
</div>
</div>
);
}
function isPhoneValid(phone: string | undefined): boolean {
if (!phone) return true; // empty is ok
return (phone.replace(/\D/g, "")).length === 12;
}
export default function ContactEditorPage() {
return (
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты">
<SectionEditor<ContactInfo>
sectionKey="contact"
title="Контакты"
defaultData={{ addresses: [], instagram: "" }}
validate={(data) => isPhoneValid(data.phone)}
>
{(data, update) => (
<>
<div className="space-y-4">
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<InputField
label="Телефон"
<div className="grid gap-4 sm:grid-cols-2">
<PhoneField
value={data.phone}
onChange={(v) => update({ ...data, phone: v })}
type="tel"
/>
<InputField
label="Instagram"
value={data.instagram}
onChange={(v) => update({ ...data, instagram: v })}
type="url"
<InstagramField
value={(data.instagram ?? "").replace(/^https?:\/\/(www\.)?instagram\.com\//, "").replace(/\/$/, "")}
onChange={(username) => update({ ...data, instagram: username ? `https://instagram.com/${username}` : "" })}
/>
<InputField
label="Часы работы"
value={data.workingHours}
onChange={(v) => update({ ...data, workingHours: v })}
/>
<ArrayEditor
label="Адреса"
items={data.addresses}
</div>
<CollapsibleSection title="Адреса">
<AddressList
items={data.addresses ?? []}
onChange={(addresses) => update({ ...data, addresses })}
renderItem={(addr, _i, updateItem) => (
<InputField label="Адрес" value={addr} onChange={updateItem} />
)}
createItem={() => ""}
addLabel="Добавить адрес"
/>
<TextareaField
label="URL карты (Yandex Maps iframe)"
value={data.mapEmbedUrl}
onChange={(v) => update({ ...data, mapEmbedUrl: v })}
rows={2}
/>
</>
</CollapsibleSection>
</div>
)}
</SectionEditor>
);
+3 -1
View File
@@ -11,7 +11,7 @@ interface FAQData {
export default function FAQEditorPage() {
return (
<SectionEditor<FAQData> sectionKey="faq" title="FAQ">
<SectionEditor<FAQData> sectionKey="faq" title="FAQ" defaultData={{ items: [] }}>
{(data, update) => (
<>
<InputField
@@ -23,6 +23,8 @@ export default function FAQEditorPage() {
label="Вопросы и ответы"
items={data.items}
onChange={(items) => update({ ...data, items })}
collapsible
getItemTitle={(item) => item.question || "Без вопроса"}
renderItem={(item, _i, updateItem) => (
<div className="space-y-3">
<InputField
+138 -29
View File
@@ -1,16 +1,23 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { useState, useRef, useCallback, useEffect } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField } from "../_components/FormField";
import { adminFetch } from "@/lib/csrf";
import { Upload, X, Loader2, Smartphone, Monitor, Star } from "lucide-react";
const MAX_VIDEO_SIZE_MB = 10;
const MAX_VIDEO_SIZE_BYTES = MAX_VIDEO_SIZE_MB * 1024 * 1024;
function formatFileSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
}
interface HeroData {
headline: string;
subheadline: string;
ctaText: string;
ctaHref: string;
videos?: string[];
}
@@ -38,6 +45,21 @@ function VideoSlot({
uploading: boolean;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [fileSize, setFileSize] = useState<number | null>(null);
// Fetch file size via HEAD request
useEffect(() => {
if (!src) { setFileSize(null); return; }
fetch(src, { method: "HEAD" })
.then((r) => {
const len = r.headers.get("content-length");
if (len) setFileSize(parseInt(len, 10));
})
.catch(() => {});
}, [src]);
const isLarge = fileSize !== null && fileSize > MAX_VIDEO_SIZE_BYTES;
return (
<div className="space-y-2">
@@ -45,7 +67,7 @@ function VideoSlot({
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-300">{label}</span>
{isCenter && (
<span className="inline-flex items-center gap-1 rounded-full bg-[#c9a96e]/15 px-2 py-0.5 text-[10px] font-medium text-[#c9a96e]">
<span className="inline-flex items-center gap-1 rounded-full bg-gold/15 px-2 py-0.5 text-[10px] font-medium text-gold">
<Smartphone size={10} />
мобильная версия
</span>
@@ -55,30 +77,45 @@ function VideoSlot({
{/* Slot */}
{src ? (
<div className={`group relative overflow-hidden rounded-lg border ${
isCenter ? "border-[#c9a96e]/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700"
}`}>
<div
className={`group relative overflow-hidden rounded-lg border ${
isCenter ? "border-gold/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700"
}`}
onMouseEnter={() => videoRef.current?.play()}
onMouseLeave={() => { videoRef.current?.pause(); }}
>
<video
ref={videoRef}
src={src}
muted
loop
playsInline
autoPlay
preload="metadata"
className="aspect-[9/16] w-full object-cover bg-black"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2">
<p className="truncate text-xs text-neutral-400">
{src.split("/").pop()}
</p>
{fileSize !== null && (
<p className={`text-[10px] mt-0.5 ${isLarge ? "text-amber-400" : "text-neutral-500"}`}>
{formatFileSize(fileSize)}{isLarge ? ` — рекомендуем до ${MAX_VIDEO_SIZE_MB} МБ` : ""}
</p>
)}
</div>
{isCenter && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-[#c9a96e]/90 px-2 py-0.5 text-[10px] font-bold text-black">
<div className="absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-gold/90 px-2 py-0.5 text-[10px] font-bold text-black">
<Star size={10} fill="currentColor" />
MAIN
</div>
)}
{/* Play hint */}
<div className="absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<span className="text-white/80 text-xs"> наведите для просмотра</span>
</div>
<button
onClick={onRemove}
aria-label={`Удалить видео: ${label}`}
className="absolute top-2 right-2 rounded-full bg-black/70 p-1.5 text-neutral-400 opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
title="Удалить"
>
@@ -91,7 +128,7 @@ function VideoSlot({
disabled={uploading}
className={`flex aspect-[9/16] w-full items-center justify-center rounded-lg border-2 border-dashed transition-colors disabled:opacity-50 ${
isCenter
? "border-[#c9a96e]/30 text-[#c9a96e]/50 hover:border-[#c9a96e]/60 hover:text-[#c9a96e]"
? "border-gold/30 text-gold/50 hover:border-gold/60 hover:text-gold"
: "border-neutral-700 text-neutral-500 hover:border-neutral-500 hover:text-neutral-300"
}`}
>
@@ -101,7 +138,7 @@ function VideoSlot({
<div className="flex flex-col items-center gap-2">
<Upload size={24} />
<span className="text-xs font-medium">Загрузить</span>
<span className="text-[10px] opacity-60">MP4, до 50МБ</span>
<span className="text-[10px] opacity-60">MP4, до {MAX_VIDEO_SIZE_MB} МБ</span>
</div>
)}
</button>
@@ -122,6 +159,39 @@ function VideoSlot({
);
}
function VideoSizeInfo({ totalSize, totalMb, rating }: { totalSize: number; totalMb: number; rating: { label: string; color: string } }) {
const [open, setOpen] = useState(false);
return (
<button
onClick={() => setOpen((v) => !v)}
className="w-full text-left rounded-lg bg-neutral-100/80 px-3 py-2 transition-colors hover:bg-neutral-200/80 dark:bg-neutral-800/50 dark:hover:bg-neutral-800/80"
>
<div className="flex items-center justify-between">
<span className="text-xs text-neutral-400">Общий вес: <span className={`font-medium ${rating.color}`}>{formatFileSize(totalSize)}</span></span>
<span className={`text-[11px] ${rating.color}`}>{rating.label} {open ? "▲" : "▼"}</span>
</div>
{open && (
<table className="w-full text-[10px] text-neutral-500 mt-2">
<tbody>
<tr className={totalMb <= 15 ? `${rating.color} font-medium` : ""}>
<td className="py-0.5 w-20">до 15 МБ</td><td>Быстро видео загружается мгновенно</td>
</tr>
<tr className={totalMb > 15 && totalMb <= 24 ? `${rating.color} font-medium` : ""}>
<td className="py-0.5">1524 МБ</td><td>Нормально небольшая задержка на 4G</td>
</tr>
<tr className={totalMb > 24 && totalMb <= 40 ? `${rating.color} font-medium` : ""}>
<td className="py-0.5">2440 МБ</td><td>Медленно заметная задержка на телефоне</td>
</tr>
<tr className={totalMb > 40 ? `${rating.color} font-medium` : ""}>
<td className="py-0.5">40+ МБ</td><td>Очень медленно пользователь может уйти</td>
</tr>
</tbody>
</table>
)}
</button>
);
}
function VideoManager({
videos,
onChange,
@@ -139,15 +209,45 @@ function VideoManager({
const syncToParent = useCallback(
(updated: (string | null)[]) => {
setSlots(updated);
// Only propagate when all 3 are filled
if (updated.every((s) => s !== null)) {
onChange(updated as string[]);
}
// Save all 3 slots (empty string for unfilled) to preserve positions
onChange(updated.map((s) => s || ""));
},
[onChange]
);
const [sizeWarning, setSizeWarning] = useState<string | null>(null);
const [fileSizes, setFileSizes] = useState<(number | null)[]>([null, null, null]);
// Fetch file sizes for all slots
useEffect(() => {
slots.forEach((src, i) => {
if (!src) { setFileSizes((p) => { const n = [...p]; n[i] = null; return n; }); return; }
fetch(src, { method: "HEAD" })
.then((r) => {
const len = r.headers.get("content-length");
if (len) setFileSizes((p) => { const n = [...p]; n[i] = parseInt(len, 10); return n; });
})
.catch(() => {});
});
}, [slots]);
const totalSize = fileSizes.reduce((sum: number, s) => sum + (s || 0), 0);
const totalMb = totalSize / (1024 * 1024);
function getLoadRating(mb: number): { label: string; color: string } {
if (mb <= 15) return { label: "Быстрая загрузка", color: "text-emerald-400" };
if (mb <= 24) return { label: "Нормальная загрузка", color: "text-blue-400" };
if (mb <= 40) return { label: "Медленная загрузка", color: "text-amber-400" };
return { label: "Очень медленная загрузка", color: "text-red-400" };
}
async function handleUpload(idx: number, file: File) {
if (file.size > MAX_VIDEO_SIZE_BYTES) {
const sizeMb = (file.size / (1024 * 1024)).toFixed(1);
alert(`Видео ${sizeMb} МБ — максимум ${MAX_VIDEO_SIZE_MB} МБ. Сожмите видео и попробуйте снова.`);
return;
}
setSizeWarning(null);
setUploadingIdx(idx);
try {
const form = new FormData();
@@ -158,14 +258,21 @@ function VideoManager({
body: form,
});
if (!res.ok) {
const err = await res.json();
alert(err.error || "Ошибка загрузки");
const text = await res.text();
let msg = "Ошибка загрузки";
try {
const err = JSON.parse(text);
msg = err.error || msg;
} catch { /* empty response */ }
alert(`${msg} (${res.status})`);
return;
}
const { path } = await res.json();
const updated = [...slots];
updated[idx] = path;
syncToParent(updated);
} catch (e) {
alert(`Ошибка сети: ${e instanceof Error ? e.message : "попробуйте снова"}`);
} finally {
setUploadingIdx(null);
}
@@ -174,8 +281,7 @@ function VideoManager({
function handleRemove(idx: number) {
const updated = [...slots];
updated[idx] = null;
setSlots(updated);
// Don't propagate incomplete state — keep old saved videos in DB
syncToParent(updated);
}
const allFilled = slots.every((s) => s !== null);
@@ -199,7 +305,7 @@ function VideoManager({
)}
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-3 max-w-3xl">
{SLOTS.map((slot, i) => (
<VideoSlot
key={slot.key}
@@ -214,7 +320,7 @@ function VideoManager({
))}
</div>
<div className="flex gap-4 rounded-lg bg-neutral-800/50 p-3 text-xs text-neutral-500">
<div className="flex gap-4 text-xs text-neutral-500">
<div className="flex items-center gap-1.5">
<Monitor size={13} />
<span>ПК диагональный сплит из 3 видео</span>
@@ -224,6 +330,15 @@ function VideoManager({
<span>Телефон только центральное видео</span>
</div>
</div>
{sizeWarning && (
<div className="rounded-lg bg-amber-500/10 border border-amber-500/20 px-3 py-2 text-xs text-amber-400">
{sizeWarning}
</div>
)}
{/* Total size — collapsible */}
{totalSize > 0 && <VideoSizeInfo totalSize={totalSize} totalMb={totalMb} rating={getLoadRating(totalMb)} />}
</div>
);
}
@@ -240,25 +355,19 @@ export default function HeroEditorPage() {
<InputField
label="Заголовок"
value={data.headline}
value={data.headline || ""}
onChange={(v) => update({ ...data, headline: v })}
/>
<InputField
label="Подзаголовок"
value={data.subheadline}
value={data.subheadline || ""}
onChange={(v) => update({ ...data, subheadline: v })}
/>
<InputField
label="Текст кнопки"
value={data.ctaText}
value={data.ctaText || ""}
onChange={(v) => update({ ...data, ctaText: v })}
/>
<InputField
label="Ссылка кнопки"
value={data.ctaHref}
onChange={(v) => update({ ...data, ctaHref: v })}
type="url"
/>
</>
)}
</SectionEditor>
+36 -22
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { adminFetch } from "@/lib/csrf";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import {
LayoutDashboard,
Sparkles,
@@ -23,22 +24,25 @@ import {
ChevronLeft,
ClipboardList,
DoorOpen,
MessageSquare,
} from "lucide-react";
const NAV_ITEMS = [
{ href: "/admin", label: "Дашборд", icon: LayoutDashboard },
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe },
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
{ href: "/admin/popups", label: "Формы записи", icon: MessageSquare },
// Sections follow user-side order: Hero → About → Classes → Team → OpenDay → Schedule → Pricing → MC → News → FAQ → Contact
{ href: "/admin/hero", label: "Главный экран", icon: Sparkles },
{ href: "/admin/about", label: "О студии", icon: FileText },
{ href: "/admin/team", label: "Команда", icon: Users },
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
{ href: "/admin/team", label: "Команда", icon: Users },
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen },
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
{ href: "/admin/news", label: "Новости", icon: Newspaper },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
{ href: "/admin/contact", label: "Контакты", icon: Phone },
];
@@ -53,17 +57,19 @@ export default function AdminLayout({
const [unreadTotal, setUnreadTotal] = useState(0);
const isLoginPage = pathname === "/admin/login";
// Fetch unread counts — poll every 10s
// Fetch unread counts — poll every 10s, stop after 3 consecutive failures
useEffect(() => {
if (isLoginPage) return;
let failures = 0;
let interval: ReturnType<typeof setInterval>;
function fetchCounts() {
adminFetch("/api/admin/unread-counts")
.then((r) => r.json())
.then((data: { total: number }) => setUnreadTotal(data.total))
.catch(() => {});
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data: { total: number }) => { setUnreadTotal(data.total); failures = 0; })
.catch(() => { failures++; if (failures >= 3 && interval) clearInterval(interval); });
}
fetchCounts();
const interval = setInterval(fetchCounts, 10000);
interval = setInterval(fetchCounts, 10000);
return () => clearInterval(interval);
}, [isLoginPage]);
@@ -83,7 +89,7 @@ export default function AdminLayout({
}
return (
<div className="flex min-h-screen bg-neutral-950 text-white">
<div className="flex min-h-screen bg-neutral-50 text-neutral-900 dark:bg-neutral-950 dark:text-white">
{/* Mobile overlay */}
{sidebarOpen && (
<div
@@ -94,23 +100,24 @@ export default function AdminLayout({
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-white/10 bg-neutral-900 transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-neutral-200 bg-white dark:border-white/10 dark:bg-neutral-900 transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-white/10 px-5 py-4">
<Link href="/admin" className="text-lg font-bold">
BLACK HEART
</Link>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden text-neutral-400 hover:text-white"
aria-label="Закрыть меню"
className="lg:hidden text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
>
<X size={20} />
</button>
</div>
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
<nav aria-label="Навигация панели управления" className="flex-1 overflow-y-auto p-3 space-y-1">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
@@ -122,13 +129,13 @@ export default function AdminLayout({
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors ${
active
? "bg-gold/10 text-gold font-medium"
: "text-neutral-400 hover:text-white hover:bg-white/5"
: "text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-white/5"
}`}
>
<Icon size={18} />
{item.label}
{item.href === "/admin/bookings" && unreadTotal > 0 && (
<span className="ml-auto rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
<span aria-label={`${unreadTotal} непрочитанных`} className="ml-auto rounded-full bg-red-500 text-white text-xs font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
{unreadTotal > 99 ? "99+" : unreadTotal}
</span>
)}
@@ -137,18 +144,22 @@ export default function AdminLayout({
})}
</nav>
<div className="border-t border-white/10 p-3 space-y-1">
<div className="border-t border-neutral-200 dark:border-white/10 p-3 space-y-1">
<div className="flex items-center justify-between px-3 py-1">
<span className="text-xs text-neutral-400 dark:text-neutral-500">Тема</span>
<ThemeToggle />
</div>
<Link
href="/"
target="_blank"
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-white hover:bg-white/5 transition-colors"
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-white/5 transition-colors"
>
<ChevronLeft size={18} />
Открыть сайт
</Link>
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-red-400 hover:bg-white/5 transition-colors"
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-500 hover:text-red-500 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-red-400 dark:hover:bg-white/5 transition-colors"
>
<LogOut size={18} />
Выйти
@@ -159,14 +170,17 @@ export default function AdminLayout({
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Top bar (mobile) */}
<header className="flex items-center gap-3 border-b border-white/10 px-4 py-3 lg:hidden">
<header className="sticky top-0 z-30 flex items-center gap-3 border-b border-neutral-200 bg-white dark:border-white/10 dark:bg-neutral-950 px-4 py-3 lg:hidden">
<button
onClick={() => setSidebarOpen(true)}
className="text-neutral-400 hover:text-white"
aria-label="Открыть меню"
aria-expanded={sidebarOpen}
className="text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
>
<Menu size={24} />
</button>
<a href="/admin" className="font-bold hover:text-gold transition-colors">BLACK HEART</a>
<a href="/admin" className="font-bold hover:text-gold transition-colors flex-1">BLACK HEART</a>
<ThemeToggle />
</header>
<main className="flex-1 p-4 sm:p-6 lg:p-8">{children}</main>

Some files were not shown because too many files have changed in this diff Show More