Compare commits

...

60 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
180 changed files with 8084 additions and 4108 deletions
+5 -4
View File
@@ -7,7 +7,7 @@ Content language: Russian
## Tech Stack ## Tech Stack
- **Next.js 16** (App Router, TypeScript, Turbopack) - **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 - **lucide-react** for icons
- **better-sqlite3** for SQLite database - **better-sqlite3** for SQLite database
- **Fonts**: Inter (body) + Oswald (headings) via `next/font` - **Fonts**: Inter (body) + Oswald (headings) via `next/font`
@@ -25,7 +25,7 @@ Content language: Russian
src/ src/
├── app/ ├── app/
│ ├── layout.tsx # Root layout, fonts, metadata │ ├── 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 │ ├── globals.css # Tailwind imports
│ ├── styles/ │ ├── styles/
│ │ ├── theme.css # Theme variables, semantic classes │ │ ├── theme.css # Theme variables, semantic classes
@@ -111,8 +111,9 @@ src/
## Brand / Styling ## Brand / Styling
- **Accent**: gold (`#c9a96e` / `hsl(37, 42%, 61%)`) - **Accent**: gold (`#c9a96e` / `hsl(37, 42%, 61%)`)
- **Background**: `#050505` `#0a0a0a` (dark only) - **Dark theme** (default): background `#050505``#0a0a0a`, surface `#171717`, text `neutral-100`
- **Surface**: `#171717` dark cards - **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` - Logo: transparent PNG heart with gold glow, uses `unoptimized`
## Content Data ## Content Data
+20
View File
@@ -1,7 +1,27 @@
import type { NextConfig } from "next"; 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 = { const nextConfig: NextConfig = {
serverExternalPackages: ["better-sqlite3"], serverExternalPackages: ["better-sqlite3"],
allowedDevOrigins: [
"black-heart.dolgolyov-family.by",
"192.168.2.56",
],
headers: async () => [
{
source: "/(.*)",
headers: securityHeaders,
},
],
}; };
export default nextConfig; export default nextConfig;
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev -H 0.0.0.0",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "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 { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom"; 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> { interface ArrayEditorProps<T> {
items: T[]; items: T[];
@@ -11,16 +14,33 @@ interface ArrayEditorProps<T> {
createItem: () => T; createItem: () => T;
label?: string; label?: string;
addLabel?: 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>({ export function ArrayEditor<T>({
items, items = [] as unknown as T[],
onChange, onChange,
renderItem, renderItem,
createItem, createItem,
label, label,
addLabel = "Добавить", addLabel = "Добавить",
collapsible = false,
getItemTitle,
getItemBadge,
hiddenItems,
addPosition = "bottom",
inline = false,
hideAdd = false,
}: ArrayEditorProps<T>) { }: ArrayEditorProps<T>) {
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null); const [dragIndex, setDragIndex] = useState<number | null>(null);
const [insertAt, setInsertAt] = useState<number | null>(null); const [insertAt, setInsertAt] = useState<number | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
@@ -29,6 +49,30 @@ export function ArrayEditor<T>({
const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [newItemIndex, setNewItemIndex] = useState<number | null>(null); 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); }, []); useEffect(() => { setMounted(true); }, []);
@@ -47,6 +91,7 @@ export function ArrayEditor<T>({
} }
function removeItem(index: number) { function removeItem(index: number) {
stableKeysRef.current.splice(index, 1);
onChange(items.filter((_, i) => i !== index)); onChange(items.filter((_, i) => i !== index));
} }
@@ -113,7 +158,14 @@ export function ArrayEditor<T>({
const updated = [...items]; const updated = [...items];
const [moved] = updated.splice(capturedDrag, 1); const [moved] = updated.splice(capturedDrag, 1);
updated.splice(targetIndex, 0, moved); 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); onChange(updated);
setDroppedIndex(targetIndex);
setTimeout(() => setDroppedIndex(null), 1500);
} }
} }
}); });
@@ -130,32 +182,96 @@ export function ArrayEditor<T>({
function renderList() { function renderList() {
if (dragIndex === null || insertAt === null) { 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 <div
key={i} key={getStableKey(i)}
ref={(el) => { itemRefs.current[i] = el; }} 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 ${ 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 ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10" 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 <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)} 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> </div>
<button <button
type="button" type="button"
onClick={() => removeItem(i)} onClick={() => setConfirmDelete(i)}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors" 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} /> <Trash2 size={16} />
</button> </button>
</div> </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))} {renderItem(item, i, (updated) => updateItem(i, updated))}
</div> </div>
)); </div>
</div>
) : (
<div className="px-4 pb-4">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
)}
</>
)}
</div>
);
});
} }
const elements: React.ReactNode[] = []; const elements: React.ReactNode[] = [];
@@ -175,36 +291,76 @@ export function ArrayEditor<T>({
elements.push( elements.push(
<div <div
key="placeholder" key="placeholder"
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3" className="rounded-lg border-2 border-dashed border-gold/40 bg-gold/5 mb-3"
style={{ height: dragSize.h }} style={{ height: collapsible ? 48 : dragSize.h }}
/> />
); );
} }
const item = items[i]; const item = items[i];
const isCollapsed = collapsible && collapsed.has(i);
const title = getItemTitle?.(item, i) || `#${i + 1}`;
elements.push( elements.push(
<div <div
key={i} key={getStableKey(i)}
ref={(el) => { itemRefs.current[i] = el; }} 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 <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)} onMouseDown={(e) => handleGripMouseDown(e, i)}
aria-label="Перетащить для сортировки"
role="button"
> >
<GripVertical size={16} /> <GripVertical size={16} />
</div> </div>
<button {collapsible && (
type="button" <button type="button" onClick={() => toggleCollapse(i)} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group">
onClick={() => removeItem(i)} <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>
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors" {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} /> <Trash2 size={16} />
</button> </button>
</div> </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))} {renderItem(item, i, (updated) => updateItem(i, updated))}
</div> </div>
</div>
</div>
) : (
<div className="px-4 pb-4">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
)}
</>
)}
</div>
); );
visualIndex++; visualIndex++;
} }
@@ -213,8 +369,8 @@ export function ArrayEditor<T>({
elements.push( elements.push(
<div <div
key="placeholder" key="placeholder"
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3" className="rounded-lg border-2 border-dashed border-gold/40 bg-gold/5 mb-3"
style={{ height: dragSize.h }} style={{ height: collapsible ? 48 : dragSize.h }}
/> />
); );
} }
@@ -224,22 +380,66 @@ export function ArrayEditor<T>({
return ( return (
<div> <div>
{label && ( {(label || (collapsible && items.length > 1)) && (
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3> <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> <div>
{renderList()} {renderList()}
</div> </div>
{!hideAdd && addPosition === "bottom" && (
<button <button
type="button" type="button"
onClick={() => { onChange([...items, createItem()]); setNewItemIndex(items.length); }} onClick={() => {
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" 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} /> <Plus size={16} />
{addLabel} {addLabel}
</button> </button>
)}
{/* Floating clone following cursor */} {/* Floating clone following cursor */}
{mounted && dragIndex !== null && {mounted && dragIndex !== null &&
@@ -253,13 +453,21 @@ export function ArrayEditor<T>({
height: dragSize.h, 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"> <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-rose-400 shrink-0" /> <GripVertical size={16} className="text-gold shrink-0" />
<span className="text-sm text-neutral-300">Перемещение элемента...</span> <span className="text-sm text-neutral-700 dark:text-neutral-300">{collapsible && dragIndex !== null ? (getItemTitle?.(items[dragIndex], dragIndex) || "Перемещение...") : "Перемещение элемента..."}</span>
</div> </div>
</div>, </div>,
document.body document.body
)} )}
<ConfirmDialog
open={confirmDelete !== null}
title="Удалить элемент?"
message="Это действие нельзя отменить."
onConfirm={() => { if (confirmDelete !== null) removeItem(confirmDelete); setConfirmDelete(null); }}
onCancel={() => setConfirmDelete(null)}
/>
</div> </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 { useRef, useEffect, useState, useMemo, useCallback } from "react";
import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-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 { adminFetch } from "@/lib/csrf";
import type { RichListItem, VictoryItem } from "@/types/content"; import type { RichListItem } from "@/types/content";
interface InputFieldProps { interface InputFieldProps {
label: string; label: string;
@@ -11,7 +12,11 @@ interface InputFieldProps {
type?: "text" | "url" | "tel"; 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({ export function InputField({
label, label,
@@ -25,7 +30,7 @@ export function InputField({
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label> <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<input <input
type={type} type={type}
value={value} value={value ?? ""}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
className={inputCls} className={inputCls}
@@ -82,16 +87,20 @@ export function ParticipantLimits({
<div> <div>
<label className="block text-sm text-neutral-400 mb-1.5">Мин. участников</label> <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)} <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" : ""}`} /> 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 ? "Поле не может быть пустым" : "Если записей меньше — занятие можно отменить"} {minEmpty ? "Поле не может быть пустым" : "Если записей меньше — занятие можно отменить"}
</p> </p>
</div> </div>
<div> <div>
<label className="block text-sm text-neutral-400 mb-1.5">Макс. участников</label> <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)} <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" : ""}`} /> 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 = без лимита. При заполнении — лист ожидания"} {maxEmpty ? "Поле не может быть пустым" : maxLocal > 0 && minLocal > maxLocal ? "Макс. не может быть меньше мин." : "0 = без лимита. При заполнении — лист ожидания"}
</p> </p>
</div> </div>
@@ -139,22 +148,268 @@ export function TextareaField({
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label> <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<textarea <textarea
ref={ref} ref={ref}
value={value} value={value ?? ""}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
rows={rows} 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> </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 { interface SelectFieldProps {
label: string; label: string;
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
options: { value: string; label: string }[]; options: { value: string; label: string }[];
placeholder?: string; placeholder?: string;
hint?: string;
} }
export function SelectField({ export function SelectField({
@@ -163,9 +418,11 @@ export function SelectField({
onChange, onChange,
options, options,
placeholder, placeholder,
hint,
}: SelectFieldProps) { }: SelectFieldProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [highlightIndex, setHighlightIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -177,6 +434,33 @@ export function SelectField({
}) })
: options; : 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
function handle(e: MouseEvent) { function handle(e: MouseEvent) {
@@ -191,51 +475,74 @@ export function SelectField({
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
{label && <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>} {label && (
<button <label className="flex items-center gap-1.5 text-sm text-neutral-400 mb-1.5">
type="button" {label}
onClick={() => { {hint && (
setOpen(!open); <span className="group relative">
setSearch(""); <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>
setTimeout(() => inputRef.current?.focus(), 0); <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}
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${ </span>
label ? "px-4 py-2.5" : "px-2 py-1 text-xs" </span>
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`} )}
> </label>
{selectedLabel || placeholder || "Выберите..."} )}
</button> {showSearch ? (
{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">
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={search} value={open ? search : selectedLabel}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => { setSearch(e.target.value); if (!open) setOpen(true); setHighlightIndex(0); }}
placeholder="Поиск..." onFocus={() => { setOpen(true); setSearch(""); }}
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" 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"> <div className="max-h-48 overflow-y-auto">
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div> <div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
)} )}
{filtered.map((opt) => ( {filtered.map((opt, idx) => (
<button <button
key={opt.value} key={opt.value || `opt-${idx}`}
type="button" type="button"
role="option"
aria-selected={opt.value === value}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setHighlightIndex(idx)}
onClick={() => { onClick={() => {
onChange(opt.value); onChange(opt.value);
setOpen(false); setOpen(false);
setSearch(""); setSearch("");
setHighlightIndex(-1);
inputRef.current?.blur();
}} }}
className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-white/5 ${ className={`w-full px-4 py-2 text-left text-sm transition-colors ${
opt.value === value ? "text-gold bg-gold/5" : "text-white" 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} {opt.label}
</button> </button>
@@ -278,7 +585,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
} }
function handleEndChange(newEnd: string) { function handleEndChange(newEnd: string) {
if (start && newEnd && newEnd <= start) return; // Always allow the change — validation handles the error display
update(start, newEnd); update(start, newEnd);
} }
@@ -291,7 +598,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
value={start} value={start}
onChange={(e) => handleStartChange(e.target.value)} onChange={(e) => handleStartChange(e.target.value)}
onBlur={onBlur} 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> <span className="text-neutral-500"></span>
<input <input
@@ -299,7 +606,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
value={end} value={end}
onChange={(e) => handleEndChange(e.target.value)} onChange={(e) => handleEndChange(e.target.value)}
onBlur={onBlur} 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>
</div> </div>
@@ -370,7 +677,7 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
type="text" type="text"
value={item} value={item}
onChange={(e) => update(i, e.target.value)} 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 <button
type="button" type="button"
@@ -387,8 +694,9 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
value={draft} value={draft}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
onBlur={add}
placeholder={placeholder || "Добавить..."} 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 <button
type="button" type="button"
@@ -410,11 +718,13 @@ interface VictoryListFieldProps {
onChange: (items: RichListItem[]) => void; onChange: (items: RichListItem[]) => void;
placeholder?: string; placeholder?: string;
onLinkValidate?: (key: string, error: string | null) => void; 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 [draft, setDraft] = useState("");
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null); const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
const [uploadError, setUploadError] = useState("");
function add() { function add() {
const val = draft.trim(); const val = draft.trim();
@@ -443,6 +753,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setUploadingIndex(index); setUploadingIndex(index);
setUploadError("");
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("folder", "team"); formData.append("folder", "team");
@@ -451,8 +762,13 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
const result = await res.json(); const result = await res.json();
if (result.path) { if (result.path) {
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item))); 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); 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> <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="space-y-2"> <div className="space-y-2">
{items.map((item, i) => ( {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"> <div className="flex items-center gap-1.5">
<input <input
type="text" type="text"
value={item.text} value={item.text}
onChange={(e) => updateText(i, e.target.value)} 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 <button
type="button" type="button"
@@ -480,7 +796,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{item.image ? ( {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" /> <ImageIcon size={10} className="text-gold" />
<span className="max-w-[80px] truncate">{item.image.split("/").pop()}</span> <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"> <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} value={draft}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
onBlur={add}
placeholder={placeholder || "Добавить..."} 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 <button
type="button" type="button"
@@ -522,150 +839,8 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
</button> </button>
</div> </div>
</div> </div>
</div> {uploadError && (
); <p role="alert" className="mt-1.5 text-xs text-red-400">{uploadError}</p>
}
// --- 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>
)} )}
</div> </div>
); );
@@ -711,8 +886,8 @@ export function ValidatedLinkField({ value, onChange, onValidate, validationKey,
validate(e.target.value); validate(e.target.value);
}} }}
placeholder={placeholder || "Ссылка..."} 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 ${ 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-white/5 focus:border-gold/50" error ? "border-red-500/50" : "border-neutral-200 focus:border-gold/50 dark:border-white/5"
}`} }`}
/> />
{error && ( {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; label: string;
items: VictoryItem[]; value: string;
onChange: (items: VictoryItem[]) => void; onChange: (v: string) => void;
cityErrors?: Record<number, string>; options: string[];
citySuggestions?: { index: number; items: string[] } | null; placeholder?: string;
onCitySearch?: (index: number, query: string) => void; }) {
onCitySelect?: (index: number, value: string) => void; const selected = useMemo(() => (value ? value.split(/\s*[,·]\s*/).filter(Boolean) : []), [value]);
onLinkValidate?: (key: string, error: string | null) => void; const [query, setQuery] = useState("");
} const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
export function VictoryItemListField({ label, items, onChange, cityErrors, citySuggestions, onCitySearch, onCitySelect, onLinkValidate }: VictoryItemListFieldProps) { const filtered = useMemo(() => {
function add() { if (!query) return options.filter((o) => !selected.includes(o));
onChange([...items, { type: "place", place: "", category: "", competition: "" }]); 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();
} }
function remove(index: number) { function removeItem(item: string) {
onChange(items.filter((_, i) => i !== index)); onChange(selected.filter((s) => s !== item).join(" · "));
} }
function update(index: number, field: keyof VictoryItem, value: string) { function handleKeyDown(e: React.KeyboardEvent) {
onChange(items.map((item, i) => (i === index ? { ...item, [field]: value || undefined } : item))); if (e.key === "Enter") {
e.preventDefault();
if (filtered.length > 0) addItem(filtered[0]);
else if (query.trim()) addItem(query.trim());
}
if (e.key === "Backspace" && !query && selected.length > 0) {
removeItem(selected[selected.length - 1]);
}
if (e.key === "Escape") { setOpen(false); setQuery(""); }
} }
return ( return (
<div> <div ref={containerRef} className="relative">
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label> <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="space-y-3"> <div
{items.map((item, i) => ( onClick={() => { setOpen(true); inputRef.current?.focus(); }}
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5"> 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 ${
<div className="flex gap-1.5"> open ? "border-gold" : "border-neutral-200 hover:border-gold/30 dark:border-white/10"
<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"
> >
<option value="place">Место</option> {selected.map((item) => (
<option value="nomination">Номинация</option> <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">
<option value="judge">Судейство</option> {item}
</select> <button type="button" onClick={(e) => { e.stopPropagation(); removeItem(item); }} className="text-gold/60 hover:text-gold transition-colors">
<input <X size={10} />
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} />
</button> </button>
</div> </span>
<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>
))} ))}
<button <input
type="button" ref={inputRef}
onClick={add} type="text"
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" value={query}
> onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
<Plus size={14} /> onFocus={() => setOpen(true)}
Добавить достижение onKeyDown={handleKeyDown}
</button> 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> </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> </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> { interface SectionEditorProps<T> {
sectionKey: string; sectionKey: string;
title: 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; children: (data: T, update: (data: T) => void) => React.ReactNode;
} }
@@ -15,14 +18,19 @@ const DEBOUNCE_MS = 800;
export function SectionEditor<T>({ export function SectionEditor<T>({
sectionKey, sectionKey,
title, title,
defaultData,
validate,
children, children,
}: SectionEditorProps<T>) { }: SectionEditorProps<T>) {
const [data, setData] = useState<T | null>(null); const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true); 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 [error, setError] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLoadRef = useRef(true); const initialLoadRef = useRef(true);
const pendingSaveRef = useRef(false);
const defaultDataRef = useRef(defaultData);
defaultDataRef.current = defaultData;
useEffect(() => { useEffect(() => {
adminFetch(`/api/admin/sections/${sectionKey}`) adminFetch(`/api/admin/sections/${sectionKey}`)
@@ -30,7 +38,7 @@ export function SectionEditor<T>({
if (!r.ok) throw new Error("Failed to load"); if (!r.ok) throw new Error("Failed to load");
return r.json(); return r.json();
}) })
.then(setData) .then((loaded) => setData(defaultDataRef.current ? { ...defaultDataRef.current, ...loaded } as T : loaded))
.catch(() => setError("Не удалось загрузить данные")) .catch(() => setError("Не удалось загрузить данные"))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [sectionKey]); }, [sectionKey]);
@@ -63,8 +71,13 @@ export function SectionEditor<T>({
return; return;
} }
pendingSaveRef.current = true;
if (timerRef.current) clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(() => {
if (validate && !validate(data)) {
setStatus("invalid");
return;
}
save(data); save(data);
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
@@ -73,6 +86,41 @@ export function SectionEditor<T>({
}; };
}, [data, save]); }, [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) { if (loading) {
return ( return (
<div className="flex items-center gap-2 text-neutral-400"> <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> <h1 className="text-2xl font-bold">{title}</h1>
{/* Fixed toast popup */} {/* Fixed toast popup */}
{(status === "saved" || status === "error") && ( {(status === "saved" || status === "error" || status === "invalid") && (
<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 ${ <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" status === "saved"
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200" ? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
: "bg-red-950/90 border-red-500/30 text-red-200" : "bg-red-950/90 border-red-500/30 text-red-200"
}`}> }`}>
{status === "saved" && <><Check size={14} /> Сохранено</>} {status === "saved" && <><Check size={14} /> Сохранено</>}
{status === "error" && <><AlertCircle size={14} /> {error}</>} {status === "error" && <><AlertCircle size={14} /> {error}</>}
{status === "invalid" && <><AlertCircle size={14} /> Не сохранено исправьте ошибки</>}
</div> </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"; "use client";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField"; import { InputField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor"; import { ArrayEditor } from "../_components/ArrayEditor";
interface AboutData { interface AboutData {
@@ -11,7 +11,7 @@ interface AboutData {
export default function AboutEditorPage() { export default function AboutEditorPage() {
return ( return (
<SectionEditor<AboutData> sectionKey="about" title="О студии"> <SectionEditor<AboutData> sectionKey="about" title="О студии" defaultData={{ paragraphs: [] }}>
{(data, update) => ( {(data, update) => (
<> <>
<InputField <InputField
@@ -23,12 +23,14 @@ export default function AboutEditorPage() {
label="Параграфы" label="Параграфы"
items={data.paragraphs} items={data.paragraphs}
onChange={(paragraphs) => update({ ...data, paragraphs })} onChange={(paragraphs) => update({ ...data, paragraphs })}
inline
renderItem={(text, _i, updateItem) => ( renderItem={(text, _i, updateItem) => (
<TextareaField <textarea
label={`Параграф`}
value={text} value={text}
onChange={updateItem} onChange={(e) => updateItem(e.target.value)}
rows={3} 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={() => ""} createItem={() => ""}
+220 -67
View File
@@ -1,16 +1,131 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef, useMemo } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { X } from "lucide-react"; import { X, ChevronDown } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import { formatBelarusPhone, SHORT_DAYS } from "@/lib/formatting";
type Tab = "classes" | "events"; type Tab = "classes" | "events";
type EventType = "master-class" | "open-day"; type EventType = "master-class" | "open-day";
interface McOption { title: string; date: string } 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 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({ export function AddBookingModal({
open, open,
@@ -32,13 +147,28 @@ export function AddBookingModal({
const [odClasses, setOdClasses] = useState<OdClass[]>([]); const [odClasses, setOdClasses] = useState<OdClass[]>([]);
const [odEventId, setOdEventId] = useState<number | null>(null); const [odEventId, setOdEventId] = useState<number | null>(null);
const [odClassId, setOdClassId] = useState(""); const [odClassId, setOdClassId] = useState("");
const [scheduleClasses, setScheduleClasses] = useState<ScheduleClass[]>([]);
const [classGroup, setClassGroup] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
if (!open) return; 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 }[] }[] }) => { 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 today = new Date().toISOString().split("T")[0];
const upcoming = (data.items || []) const upcoming = (data.items || [])
@@ -74,34 +204,61 @@ export function AddBookingModal({
}, [open, onClose]); }, [open, onClose]);
function handlePhoneChange(raw: string) { function handlePhoneChange(raw: string) {
let digits = raw.replace(/\D/g, ""); setPhone(formatBelarusPhone(raw));
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);
} }
const hasUpcomingMc = mcOptions.length > 0; const hasUpcomingMc = mcOptions.length > 0;
const hasOpenDay = odEventId !== null && odClasses.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() { async function handleSubmit() {
if (!name.trim() || !phone.trim()) return; if (!name.trim() || !phone.trim()) return;
setSaving(true); setSaving(true);
try { try {
if (tab === "classes") { if (tab === "classes") {
const groupInfo = classGroup
? classGroupOptions.find((o) => o.value === classGroup)?.label
: undefined;
await adminFetch("/api/admin/group-bookings", { await adminFetch("/api/admin/group-bookings", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
name: name.trim(), name: name.trim(),
phone: phone.trim(), phone: phone.trim(),
...(groupInfo && { groupInfo }),
...(instagram.trim() && { instagram: instagram.trim() }), ...(instagram.trim() && { instagram: instagram.trim() }),
...(telegram.trim() && { telegram: telegram.trim() }), ...(telegram.trim() && { telegram: telegram.trim() }),
}), }),
@@ -122,6 +279,8 @@ export function AddBookingModal({
} }
onAdded(); onAdded();
onClose(); onClose();
} catch {
alert("Не удалось создать запись. Попробуйте ещё раз.");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -129,19 +288,7 @@ export function AddBookingModal({
if (!open) return null; 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 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 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 canSubmit = name.trim() && phone.trim() && !saving const canSubmit = name.trim() && phone.trim() && !saving
&& (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc) && (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc)
@@ -150,29 +297,30 @@ export function AddBookingModal({
return createPortal( return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}> <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="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()}> <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-white/[0.06] hover:text-white"> <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} /> <X size={16} />
</button> </button>
<h3 className="text-base font-bold text-white">Добавить запись</h3> <h3 className="text-base font-bold text-neutral-900 dark:text-white">Добавить запись</h3>
<p className="mt-1 text-xs text-neutral-400">Ручная запись (Instagram, звонок, лично)</p> <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{/* Tab: Classes vs Events */} {/* Type selector — single row */}
<div className="flex gap-2"> <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]">
{tabBtn("classes", "Занятие")} <button
{tabBtn("events", "Мероприятие", !hasEvents)} onClick={() => setTab("classes")}
</div> 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"
{/* Events sub-selector */} }`}
{tab === "events" && ( >
<div className="flex gap-2"> Занятие
</button>
{hasUpcomingMc && ( {hasUpcomingMc && (
<button <button
onClick={() => setEventType("master-class")} onClick={() => { setTab("events"); setEventType("master-class"); }}
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${ className={`flex-1 rounded-md py-2 text-xs 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" 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 && ( {hasOpenDay && (
<button <button
onClick={() => setEventType("open-day")} onClick={() => { setTab("events"); setEventType("open-day"); }}
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${ className={`flex-1 rounded-md py-2 text-xs 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" 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 Open Day
</button> </button>
)} )}
</div> </div>
{/* Class selector (optional for Занятие) */}
{tab === "classes" && classGroupOptions.length > 0 && (
<SearchSelect
options={classGroupOptions}
value={classGroup}
onChange={setClassGroup}
placeholder="Группа (необязательно)"
/>
)} )}
{/* MC selector */} {/* MC selector */}
{tab === "events" && eventType === "master-class" && mcOptions.length > 0 && ( {tab === "events" && eventType === "master-class" && mcSelectOptions.length > 0 && (
<select value={mcTitle} onChange={(e) => setMcTitle(e.target.value)} className={inputClass + " [color-scheme:dark]"}> <SearchSelect
<option value="" className="bg-neutral-900">Выберите мастер-класс</option> options={mcSelectOptions}
{mcOptions.map((mc) => ( value={mcTitle}
<option key={mc.title} value={mc.title} className="bg-neutral-900"> onChange={setMcTitle}
{mc.title} placeholder="Выберите мастер-класс"
</option> />
))}
</select>
)} )}
{/* Open Day class selector */} {/* Open Day class selector */}
{tab === "events" && eventType === "open-day" && odClasses.length > 0 && ( {tab === "events" && eventType === "open-day" && odSelectOptions.length > 0 && (
<select value={odClassId} onChange={(e) => setOdClassId(e.target.value)} className={inputClass + " [color-scheme:dark]"}> <SearchSelect
<option value="" className="bg-neutral-900">Выберите занятие</option> options={odSelectOptions}
{odClasses.map((c) => ( value={odClassId}
<option key={c.id} value={c.id} className="bg-neutral-900"> onChange={setOdClassId}
{c.time} · {c.style} · {c.hall} placeholder="Выберите занятие"
</option> />
))}
</select>
)} )}
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Имя" className={inputClass} /> <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Имя" className={inputClass} />
+19 -19
View File
@@ -47,17 +47,17 @@ export function DeleteBtn({ onClick, name }: { onClick: () => void; name?: strin
{confirming && createPortal( {confirming && createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirming(false)}> <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="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()}> <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-white/[0.06] hover:text-white"> <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} /> <X size={16} />
</button> </button>
<h3 className="text-sm font-bold text-white">Удалить запись?</h3> <h3 className="text-sm font-bold text-neutral-900 dark:text-white">Удалить запись?</h3>
{name && <p className="mt-1 text-xs text-neutral-400">{name}</p>} {name && <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{name}</p>}
<p className="mt-2 text-xs text-neutral-500">Это действие нельзя отменить.</p> <p className="mt-2 text-xs text-neutral-400 dark:text-neutral-500">Это действие нельзя отменить.</p>
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
<button <button
onClick={() => setConfirming(false)} 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> </button>
@@ -80,17 +80,17 @@ export function ContactLinks({ phone, instagram, telegram }: { phone?: string; i
return ( return (
<> <>
{phone && ( {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} <Phone size={10} />{phone}
</a> </a>
)} )}
{instagram && ( {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} <Instagram size={10} />{instagram}
</a> </a>
)} )}
{telegram && ( {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} <Send size={10} />{telegram}
</a> </a>
)} )}
@@ -109,7 +109,7 @@ export function FilterTabs({ filter, counts, total, onFilter }: {
<button <button
onClick={() => onFilter("all")} onClick={() => onFilter("all")}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-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> Все <span className="text-neutral-500 ml-1">{total}</span>
@@ -119,7 +119,7 @@ export function FilterTabs({ filter, counts, total, onFilter }: {
key={s.key} key={s.key}
onClick={() => onFilter(s.key)} onClick={() => onFilter(s.key)}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${ 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} {s.label}
@@ -147,14 +147,14 @@ export function StatusActions({ status, onStatus }: { status: BookingStatus; onS
); );
return ( return (
<div className="flex gap-1 ml-auto"> <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" && ( {status === "contacted" && (
<> <>
{actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-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-400 border border-red-500/30 hover:bg-red-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> </div>
); );
} }
@@ -163,10 +163,10 @@ export function BookingCard({ status, highlight, children }: { status: BookingSt
return ( return (
<div <div
className={`rounded-lg border p-3 transition-all duration-200 cursor-default ${ className={`rounded-lg border p-3 transition-all duration-200 cursor-default ${
status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50 hover:opacity-70 hover:border-red-500/30" 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/15 bg-emerald-500/[0.02] hover:border-emerald-500/30 hover:bg-emerald-500/[0.05]" : 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/20 bg-gold/[0.03] hover:border-gold/40 hover:bg-gold/[0.06]" : 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-white/10 bg-neutral-800/30 hover:border-white/20 hover:bg-neutral-800/50" : "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]" : ""}`} }${highlight ? " ring-2 ring-gold/40 animate-[pulse_1s_ease-in-out_1]" : ""}`}
> >
{children} {children}
+26 -12
View File
@@ -109,7 +109,7 @@ export function GenericBookingsList<T extends BaseBooking>({
<BookingCard status={item.status} highlight={isHighlighted}> <BookingCard status={item.status} highlight={isHighlighted}>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0"> <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} /> <ContactLinks phone={item.phone} instagram={item.instagram} telegram={item.telegram} />
{renderExtra?.(item)} {renderExtra?.(item)}
</div> </div>
@@ -144,42 +144,56 @@ export function GenericBookingsList<T extends BaseBooking>({
const groupCounts = { new: 0, contacted: 0, confirmed: 0, declined: 0 }; const groupCounts = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
for (const item of group.items) groupCounts[item.status] = (groupCounts[item.status] || 0) + 1; for (const item of group.items) groupCounts[item.status] = (groupCounts[item.status] || 0) + 1;
return ( 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 <button
onClick={() => setExpanded((p) => ({ ...p, [group.key]: !isOpen }))} 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" />} {isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
{group.sublabel && ( {group.sublabel && (
<span className={`text-xs font-medium shrink-0 ${group.isArchived ? "text-neutral-500" : "text-gold"}`}>{group.sublabel}</span> <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 && ( {group.dateBadge && (
<span className={`text-[10px] rounded-full px-2 py-0.5 shrink-0 ${ <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} {group.dateBadge}
</span> </span>
)} )}
{group.isArchived && ( {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 && ( {!group.isArchived && (
<div className="flex gap-2 ml-auto text-[10px]"> <div className="flex gap-2 ml-auto text-[10px]">
{groupCounts.new > 0 && <span className="text-gold">{groupCounts.new} новых</span>} {groupCounts.new > 0 && <span className="text-amber-700 dark:text-gold">{groupCounts.new} новых</span>}
{groupCounts.contacted > 0 && <span className="text-blue-400">{groupCounts.contacted} связ.</span>} {groupCounts.contacted > 0 && <span className="text-blue-600 dark:text-blue-400">{groupCounts.contacted} связ.</span>}
{groupCounts.confirmed > 0 && <span className="text-emerald-400">{groupCounts.confirmed} подтв.</span>} {groupCounts.confirmed > 0 && <span className="text-emerald-600 dark:text-emerald-400">{groupCounts.confirmed} подтв.</span>}
</div> </div>
)} )}
</button> </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"> <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> </div>
{waiting.map((item) => renderItem(item, group.isArchived))}
</>
)} )}
</div> </div>
); );
})()}
</div>
);
} }
return ( return (
+2 -2
View File
@@ -43,10 +43,10 @@ export function SearchBar({
value={query} value={query}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
placeholder="Поиск по имени или телефону..." 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 && ( {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} /> <X size={14} />
</button> </button>
)} )}
+2 -68
View File
@@ -1,68 +1,2 @@
"use client"; // Re-export from shared location
export { ToastProvider, useToast } from "../_components/Toast";
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>
);
}
+80 -56
View File
@@ -28,6 +28,7 @@ interface GroupBooking {
status: BookingStatus; status: BookingStatus;
confirmedDate?: string; confirmedDate?: string;
confirmedGroup?: string; confirmedGroup?: string;
confirmedHall?: string;
confirmedComment?: string; confirmedComment?: string;
notes?: string; notes?: string;
createdAt: string; createdAt: string;
@@ -160,43 +161,43 @@ function ConfirmModal({
if (!open) return null; 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( 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="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="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()}> <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-white/[0.06] hover:text-white"> <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} /> <X size={16} />
</button> </button>
<h3 className="text-base font-bold text-white">Подтвердить запись</h3> <h3 className="text-base font-bold text-neutral-900 dark:text-white">Подтвердить запись</h3>
<p className="mt-1 text-xs text-neutral-400">{bookingName}</p> <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{bookingName}</p>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<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={hall} onChange={(e) => setHall(e.target.value)} className={selectClass}> <select value={hall} onChange={(e) => setHall(e.target.value)} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите зал</option> <option value="" className="bg-white dark:bg-neutral-900">Выберите зал</option>
{halls.map((h) => <option key={h} value={h} className="bg-neutral-900">{h}</option>)} {halls.map((h) => <option key={h} value={h} className="bg-white dark:bg-neutral-900">{h}</option>)}
</select> </select>
</div> </div>
<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}> <select value={trainer} onChange={(e) => setTrainer(e.target.value)} disabled={!hall} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите тренера</option> <option value="" className="bg-white dark:bg-neutral-900">Выберите тренера</option>
{trainers.map((t) => <option key={t} value={t} className="bg-neutral-900">{t}</option>)} {trainers.map((t) => <option key={t} value={t} className="bg-white dark:bg-neutral-900">{t}</option>)}
</select> </select>
</div> </div>
<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}> <select value={group} onChange={(e) => setGroup(e.target.value)} disabled={!trainer} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите группу</option> <option value="" className="bg-white dark:bg-neutral-900">Выберите группу</option>
{groups.map((g) => <option key={g.value} value={g.value} className="bg-neutral-900">{g.label}</option>)} {groups.map((g) => <option key={g.value} value={g.value} className="bg-white dark:bg-neutral-900">{g.label}</option>)}
</select> </select>
</div> </div>
<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 <input
type="date" type="date"
value={date} value={date}
@@ -211,14 +212,14 @@ function ConfirmModal({
)} )}
</div> </div>
<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 <input
type="text" type="text"
value={comment} value={comment}
disabled={!group} disabled={!group}
onChange={(e) => setComment(e.target.value)} onChange={(e) => setComment(e.target.value)}
placeholder="Первое занятие, пробный" 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>
</div> </div>
@@ -282,6 +283,7 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
...b, status: "confirmed" as BookingStatus, ...b, status: "confirmed" as BookingStatus,
confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes, confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes,
} : b)); } : b));
try {
await Promise.all([ await Promise.all([
adminFetch("/api/admin/group-bookings", { adminFetch("/api/admin/group-bookings", {
method: "PUT", method: "PUT",
@@ -294,6 +296,10 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }), body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
}) : Promise.resolve(), }) : Promise.resolve(),
]); ]);
} catch {
// Revert optimistic update on failure
setBookings((prev) => prev.map((b) => b.id === confirmingId ? { ...b, ...existing } : b));
}
setConfirmingId(null); setConfirmingId(null);
onDataChange?.(); onDataChange?.();
} }
@@ -312,8 +318,8 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
onConfirm={(id) => setConfirmingId(id)} onConfirm={(id) => setConfirmingId(id)}
renderExtra={(b) => ( 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-800 rounded-full px-2 py-0.5">{b.confirmedHall}</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) && ( {(b.confirmedGroup || b.confirmedDate) && (
<button <button
onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }} onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }}
@@ -465,11 +471,11 @@ function RemindersTab() {
: currentStatus === "coming" ? "border-emerald-500/15 bg-emerald-500/[0.02]" : 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 === "cancelled" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
: currentStatus === "pending" ? "border-amber-500/15 bg-amber-500/[0.02]" : 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"> <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 && ( {item.phone && (
<a href={`tel:${item.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs"> <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} <Phone size={10} />{item.phone}
@@ -536,11 +542,11 @@ function RemindersTab() {
const TypeIcon = typeConf.icon; const TypeIcon = typeConf.icon;
const egStats = countByStatus(eg.items); const egStats = countByStatus(eg.items);
return ( return (
<div key={eg.label} className="rounded-xl border border-white/10 overflow-hidden"> <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-900"> <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} /> <TypeIcon size={13} className={typeConf.color} />
<span className="text-sm font-medium text-white">{eg.label}{eg.items[0]?.eventHall ? ` · ${eg.items[0].eventHall}` : ""}</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-800 rounded-full px-2 py-0.5">{eg.items.length} чел.</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]"> <div className="flex gap-2 ml-auto text-[10px]">
{egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>} {egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>}
{egStats.cancelled > 0 && <span className="text-red-400">{egStats.cancelled} не придёт</span>} {egStats.cancelled > 0 && <span className="text-red-400">{egStats.cancelled} не придёт</span>}
@@ -596,10 +602,12 @@ function countByStatus(items: { status: string }[]): TabCounts {
} }
function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: { function DashboardSummary({ refreshTrigger, onNavigate, onFilter, activeTab, activeFilter }: {
refreshTrigger: number; refreshTrigger: number;
onNavigate: (tab: Tab) => void; onNavigate: (tab: Tab) => void;
onFilter: (f: BookingFilter) => void; onFilter: (f: BookingFilter) => void;
activeTab: Tab;
activeFilter: BookingFilter;
}) { }) {
const [counts, setCounts] = useState<DashboardCounts | null>(null); const [counts, setCounts] = useState<DashboardCounts | null>(null);
@@ -664,15 +672,15 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
if (c.tab === "reminders") { if (c.tab === "reminders") {
const total = counts.remindersToday + counts.remindersTomorrow; const total = counts.remindersToday + counts.remindersTomorrow;
if (total === 0) return ( 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-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> </div>
); );
return ( return (
<button key={c.tab} onClick={() => onNavigate(c.tab)} <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]`}> 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-400">{c.label}</p> <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"> <div className="flex items-baseline gap-2 mt-1 flex-wrap">
{counts.remindersNotAsked > 0 && ( {counts.remindersNotAsked > 0 && (
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all" <span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
@@ -710,20 +718,25 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
const tc = c.counts!; const tc = c.counts!;
const total = tc.new + tc.contacted + tc.confirmed + tc.declined; const total = tc.new + tc.contacted + tc.confirmed + tc.declined;
if (total === 0) return ( 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-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> </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 ( return (
<button key={c.tab} onClick={() => { onNavigate(c.tab); onFilter("all"); }} <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]`}> 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-400">{c.label}</p> <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"> <div className="flex items-baseline gap-2 mt-1 flex-wrap">
{tc.new > 0 && ( {tc.new > 0 && (
<> <>
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all" <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("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-lg font-bold text-gold">{tc.new}</span>
<span className="text-[10px] text-neutral-500">новых</span> <span className="text-[10px] text-neutral-500">новых</span>
</span> </span>
@@ -732,8 +745,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
{tc.contacted > 0 && ( {tc.contacted > 0 && (
<> <>
{tc.new > 0 && <span className="text-neutral-700">·</span>} {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" <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("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-sm font-medium text-blue-400">{tc.contacted}</span>
<span className="text-[10px] text-neutral-500">в работе</span> <span className="text-[10px] text-neutral-500">в работе</span>
</span> </span>
@@ -742,8 +755,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
{tc.confirmed > 0 && ( {tc.confirmed > 0 && (
<> <>
{(tc.new > 0 || tc.contacted > 0) && <span className="text-neutral-700">·</span>} {(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" <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("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-sm font-medium text-emerald-400">{tc.confirmed}</span>
<span className="text-[10px] text-neutral-500">подтв.</span> <span className="text-[10px] text-neutral-500">подтв.</span>
</span> </span>
@@ -752,8 +765,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
{tc.declined > 0 && ( {tc.declined > 0 && (
<> <>
{(tc.new > 0 || tc.contacted > 0 || tc.confirmed > 0) && <span className="text-neutral-700">·</span>} {(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" <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("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-sm font-medium text-red-400">{tc.declined}</span>
<span className="text-[10px] text-neutral-500">отказ</span> <span className="text-[10px] text-neutral-500">отказ</span>
</span> </span>
@@ -884,7 +897,7 @@ function BookingsPageInner() {
<button <button
onClick={() => setHallFilter("all")} onClick={() => setHallFilter("all")}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${ 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-white border border-transparent" 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"
}`} }`}
> >
Все залы Все залы
@@ -894,7 +907,7 @@ function BookingsPageInner() {
key={hall} key={hall}
onClick={() => setHallFilter(hallFilter === hall ? "all" : hall)} onClick={() => setHallFilter(hallFilter === hall ? "all" : hall)}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${ 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-white border border-transparent" 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} {hall}
@@ -916,10 +929,10 @@ function BookingsPageInner() {
<BookingCard key={`${r.type}-${r.id}`} status={r.status as BookingStatus}> <BookingCard key={`${r.type}-${r.id}`} status={r.status as BookingStatus}>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0"> <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="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-white">{r.name}</span> <span className="font-medium text-neutral-900 dark:text-white">{r.name}</span>
<ContactLinks phone={r.phone} instagram={r.instagram} telegram={r.telegram} /> <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>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="text-neutral-600 text-xs">{fmtDate(r.createdAt)}</span> <span className="text-neutral-600 text-xs">{fmtDate(r.createdAt)}</span>
@@ -940,16 +953,27 @@ function BookingsPageInner() {
) : ( ) : (
<> <>
{/* Dashboard — what needs attention */} {/* 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 */} {/* Tabs — select on mobile, tabs on desktop */}
<div className="mt-5 flex border-b border-white/10"> <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) => ( {TABS.map((t) => (
<button <button
key={t.key} key={t.key}
onClick={() => setTab(t.key)} onClick={() => setTab(t.key)}
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${ className={`shrink-0 px-4 py-2.5 text-sm font-medium transition-colors relative whitespace-nowrap ${
tab === t.key ? "text-gold" : "text-neutral-400 hover:text-white" tab === t.key ? "text-gold" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
}`} }`}
> >
{t.label} {t.label}
@@ -963,9 +987,9 @@ function BookingsPageInner() {
{/* Tab content */} {/* Tab content */}
<div className="mt-4"> <div className="mt-4">
{tab === "reminders" && <RemindersTab key={refreshKey} />} {tab === "reminders" && <RemindersTab key={refreshKey} />}
{tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />} {tab === "classes" && <GroupBookingsTab key={refreshKey} filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />} {tab === "master-classes" && <McRegistrationsTab key={refreshKey} filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />} {tab === "open-day" && <OpenDayBookingsTab key={refreshKey} filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />}
</div> </div>
</> </>
)} )}
@@ -973,7 +997,7 @@ function BookingsPageInner() {
<AddBookingModal <AddBookingModal
open={addOpen} open={addOpen}
onClose={() => setAddOpen(false)} onClose={() => setAddOpen(false)}
onAdded={() => { setRefreshKey((k) => k + 1); refreshDashboard(); }} onAdded={() => { setStatusFilter("all"); setRefreshKey((k) => k + 1); refreshDashboard(); }}
/> />
</div> </div>
); );
+17 -9
View File
@@ -1,3 +1,5 @@
import { SHORT_DAYS } from "@/lib/formatting";
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined"; export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
export type BookingFilter = "all" | BookingStatus; export type BookingFilter = "all" | BookingStatus;
@@ -12,20 +14,26 @@ export interface BaseBooking {
createdAt: string; 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 }[] = [ 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: "new", label: "Новая", color: "text-amber-700 dark: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: "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-400", bg: "bg-emerald-500/10", border: "border-emerald-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-400", bg: "bg-red-500/10", border: "border-red-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 { 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> { export function countStatuses(items: { status: string }[]): Record<string, number> {
+80 -18
View File
@@ -2,23 +2,66 @@
import { useState, useRef, useEffect, useMemo } from "react"; import { useState, useRef, useEffect, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField"; import { InputField, TextareaField, RichTextarea } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor"; 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" // PascalCase "HeartPulse" → kebab "heart-pulse"
function toKebab(name: string) { function toKebab(name: string) {
return name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); 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]) => ({ const ALL_ICONS = Object.entries(icons).map(([name, Icon]) => ({
key: toKebab(name), key: toKebab(name),
Icon: Icon as LucideIcon, Icon: Icon as LucideIcon,
label: name, 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({ function IconPicker({
value, value,
@@ -46,9 +89,12 @@ function IconPicker({
}, [open]); }, [open]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search) return ALL_ICONS.slice(0, 60); if (!search) return CURATED_ICONS;
const q = search.toLowerCase(); 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]); }, [search]);
const SelectedIcon = selected?.Icon; const SelectedIcon = selected?.Icon;
@@ -63,8 +109,8 @@ function IconPicker({
setSearch(""); setSearch("");
setTimeout(() => inputRef.current?.focus(), 0); 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 ${ 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-white/10" open ? "border-gold" : "border-neutral-200 dark:border-white/10"
}`} }`}
> >
{SelectedIcon ? ( {SelectedIcon ? (
@@ -72,21 +118,21 @@ function IconPicker({
<SelectedIcon size={16} /> <SelectedIcon size={16} />
</span> </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> <span className="text-sm">{selected?.label || value}</span>
</button> </button>
{open && ( {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"> <div className="p-2 pb-0">
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск иконки... (flame, heart, star...)" 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" 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>
<div className="p-2 max-h-56 overflow-y-auto"> <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 ${ className={`flex flex-col items-center gap-0.5 rounded-lg p-2 transition-colors ${
key === value key === value
? "bg-gold/20 text-gold-light" ? "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} /> <Icon size={20} />
@@ -150,13 +196,16 @@ interface ClassesData {
icon: string; icon: string;
detailedDescription?: string; detailedDescription?: string;
images?: string[]; images?: string[];
imageFocalX?: number;
imageFocalY?: number;
imageZoom?: number;
color?: string; color?: string;
}[]; }[];
} }
export default function ClassesEditorPage() { export default function ClassesEditorPage() {
return ( return (
<SectionEditor<ClassesData> sectionKey="classes" title="Направления"> <SectionEditor<ClassesData> sectionKey="classes" title="Направления" defaultData={{ items: [] }}>
{(data, update) => ( {(data, update) => (
<> <>
<InputField <InputField
@@ -188,18 +237,21 @@ export default function ClassesEditorPage() {
</label> </label>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{COLOR_SWATCHES.map((c) => { {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 (other) => other !== item && other.color === c.value
); );
if (isUsed) return null;
return ( return (
<button <button
key={c.value} key={c.value}
type="button" type="button"
disabled={isUsed}
onClick={() => updateItem({ ...item, color: c.value })} onClick={() => updateItem({ ...item, color: c.value })}
className={`h-6 w-6 rounded-full ${c.bg} transition-all ${ 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" ? "ring-2 ring-white ring-offset-1 ring-offset-neutral-900 scale-110"
: isUsed
? "opacity-15 cursor-not-allowed"
: "opacity-50 hover:opacity-100" : "opacity-50 hover:opacity-100"
}`} }`}
/> />
@@ -207,13 +259,21 @@ export default function ClassesEditorPage() {
})} })}
</div> </div>
</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 <TextareaField
label="Краткое описание" label="Краткое описание"
value={item.description} value={item.description}
onChange={(v) => updateItem({ ...item, description: v })} onChange={(v) => updateItem({ ...item, description: v })}
rows={2} rows={2}
/> />
<TextareaField <RichTextarea
label="Подробное описание" label="Подробное описание"
value={item.detailedDescription || ""} value={item.detailedDescription || ""}
onChange={(v) => onChange={(v) =>
@@ -231,6 +291,8 @@ export default function ClassesEditorPage() {
images: [], images: [],
})} })}
addLabel="Добавить направление" addLabel="Добавить направление"
collapsible
getItemTitle={(item) => item.name || "Без названия"}
/> />
</> </>
)} )}
+220 -32
View File
@@ -1,54 +1,242 @@
"use client"; "use client";
import { useState, useRef, useCallback } from "react";
import { Plus, X, AlertCircle, Check, Loader2 } from "lucide-react";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField"; import { InputField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor"; import { CollapsibleSection } from "../_components/CollapsibleSection";
import { adminFetch } from "@/lib/csrf";
import type { ContactInfo } from "@/types/content"; 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() { export default function ContactEditorPage() {
return ( return (
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты"> <SectionEditor<ContactInfo>
sectionKey="contact"
title="Контакты"
defaultData={{ addresses: [], instagram: "" }}
validate={(data) => isPhoneValid(data.phone)}
>
{(data, update) => ( {(data, update) => (
<> <div className="space-y-4">
<InputField <InputField
label="Заголовок секции" label="Заголовок секции"
value={data.title} value={data.title}
onChange={(v) => update({ ...data, title: v })} onChange={(v) => update({ ...data, title: v })}
/> />
<InputField
label="Телефон" <div className="grid gap-4 sm:grid-cols-2">
<PhoneField
value={data.phone} value={data.phone}
onChange={(v) => update({ ...data, phone: v })} onChange={(v) => update({ ...data, phone: v })}
type="tel"
/> />
<InputField <InstagramField
label="Instagram" value={(data.instagram ?? "").replace(/^https?:\/\/(www\.)?instagram\.com\//, "").replace(/\/$/, "")}
value={data.instagram} onChange={(username) => update({ ...data, instagram: username ? `https://instagram.com/${username}` : "" })}
onChange={(v) => update({ ...data, instagram: v })}
type="url"
/> />
<InputField </div>
label="Часы работы"
value={data.workingHours} <CollapsibleSection title="Адреса">
onChange={(v) => update({ ...data, workingHours: v })} <AddressList
/> items={data.addresses ?? []}
<ArrayEditor
label="Адреса"
items={data.addresses}
onChange={(addresses) => update({ ...data, addresses })} onChange={(addresses) => update({ ...data, addresses })}
renderItem={(addr, _i, updateItem) => (
<InputField label="Адрес" value={addr} onChange={updateItem} />
)}
createItem={() => ""}
addLabel="Добавить адрес"
/> />
<TextareaField </CollapsibleSection>
label="URL карты (Yandex Maps iframe)" </div>
value={data.mapEmbedUrl}
onChange={(v) => update({ ...data, mapEmbedUrl: v })}
rows={2}
/>
</>
)} )}
</SectionEditor> </SectionEditor>
); );
+3 -1
View File
@@ -11,7 +11,7 @@ interface FAQData {
export default function FAQEditorPage() { export default function FAQEditorPage() {
return ( return (
<SectionEditor<FAQData> sectionKey="faq" title="FAQ"> <SectionEditor<FAQData> sectionKey="faq" title="FAQ" defaultData={{ items: [] }}>
{(data, update) => ( {(data, update) => (
<> <>
<InputField <InputField
@@ -23,6 +23,8 @@ export default function FAQEditorPage() {
label="Вопросы и ответы" label="Вопросы и ответы"
items={data.items} items={data.items}
onChange={(items) => update({ ...data, items })} onChange={(items) => update({ ...data, items })}
collapsible
getItemTitle={(item) => item.question || "Без вопроса"}
renderItem={(item, _i, updateItem) => ( renderItem={(item, _i, updateItem) => (
<div className="space-y-3"> <div className="space-y-3">
<InputField <InputField
+138 -29
View File
@@ -1,16 +1,23 @@
"use client"; "use client";
import { useState, useRef, useCallback } from "react"; import { useState, useRef, useCallback, useEffect } from "react";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField } from "../_components/FormField"; import { InputField } from "../_components/FormField";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import { Upload, X, Loader2, Smartphone, Monitor, Star } from "lucide-react"; 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 { interface HeroData {
headline: string; headline: string;
subheadline: string; subheadline: string;
ctaText: string; ctaText: string;
ctaHref: string;
videos?: string[]; videos?: string[];
} }
@@ -38,6 +45,21 @@ function VideoSlot({
uploading: boolean; uploading: boolean;
}) { }) {
const fileRef = useRef<HTMLInputElement>(null); 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 ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -45,7 +67,7 @@ function VideoSlot({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-300">{label}</span> <span className="text-sm font-medium text-neutral-300">{label}</span>
{isCenter && ( {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} /> <Smartphone size={10} />
мобильная версия мобильная версия
</span> </span>
@@ -55,30 +77,45 @@ function VideoSlot({
{/* Slot */} {/* Slot */}
{src ? ( {src ? (
<div className={`group relative overflow-hidden rounded-lg border ${ <div
isCenter ? "border-[#c9a96e]/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700" 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 <video
ref={videoRef}
src={src} src={src}
muted muted
loop loop
playsInline playsInline
autoPlay preload="metadata"
className="aspect-[9/16] w-full object-cover bg-black" 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"> <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"> <p className="truncate text-xs text-neutral-400">
{src.split("/").pop()} {src.split("/").pop()}
</p> </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> </div>
{isCenter && ( {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" /> <Star size={10} fill="currentColor" />
MAIN MAIN
</div> </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 <button
onClick={onRemove} 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" 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="Удалить" title="Удалить"
> >
@@ -91,7 +128,7 @@ function VideoSlot({
disabled={uploading} disabled={uploading}
className={`flex aspect-[9/16] w-full items-center justify-center rounded-lg border-2 border-dashed transition-colors disabled:opacity-50 ${ className={`flex aspect-[9/16] w-full items-center justify-center rounded-lg border-2 border-dashed transition-colors disabled:opacity-50 ${
isCenter 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" : "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"> <div className="flex flex-col items-center gap-2">
<Upload size={24} /> <Upload size={24} />
<span className="text-xs font-medium">Загрузить</span> <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> </div>
)} )}
</button> </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({ function VideoManager({
videos, videos,
onChange, onChange,
@@ -139,15 +209,45 @@ function VideoManager({
const syncToParent = useCallback( const syncToParent = useCallback(
(updated: (string | null)[]) => { (updated: (string | null)[]) => {
setSlots(updated); setSlots(updated);
// Only propagate when all 3 are filled // Save all 3 slots (empty string for unfilled) to preserve positions
if (updated.every((s) => s !== null)) { onChange(updated.map((s) => s || ""));
onChange(updated as string[]);
}
}, },
[onChange] [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) { 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); setUploadingIdx(idx);
try { try {
const form = new FormData(); const form = new FormData();
@@ -158,14 +258,21 @@ function VideoManager({
body: form, body: form,
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json(); const text = await res.text();
alert(err.error || "Ошибка загрузки"); let msg = "Ошибка загрузки";
try {
const err = JSON.parse(text);
msg = err.error || msg;
} catch { /* empty response */ }
alert(`${msg} (${res.status})`);
return; return;
} }
const { path } = await res.json(); const { path } = await res.json();
const updated = [...slots]; const updated = [...slots];
updated[idx] = path; updated[idx] = path;
syncToParent(updated); syncToParent(updated);
} catch (e) {
alert(`Ошибка сети: ${e instanceof Error ? e.message : "попробуйте снова"}`);
} finally { } finally {
setUploadingIdx(null); setUploadingIdx(null);
} }
@@ -174,8 +281,7 @@ function VideoManager({
function handleRemove(idx: number) { function handleRemove(idx: number) {
const updated = [...slots]; const updated = [...slots];
updated[idx] = null; updated[idx] = null;
setSlots(updated); syncToParent(updated);
// Don't propagate incomplete state — keep old saved videos in DB
} }
const allFilled = slots.every((s) => s !== null); const allFilled = slots.every((s) => s !== null);
@@ -199,7 +305,7 @@ function VideoManager({
)} )}
</div> </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) => ( {SLOTS.map((slot, i) => (
<VideoSlot <VideoSlot
key={slot.key} key={slot.key}
@@ -214,7 +320,7 @@ function VideoManager({
))} ))}
</div> </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"> <div className="flex items-center gap-1.5">
<Monitor size={13} /> <Monitor size={13} />
<span>ПК диагональный сплит из 3 видео</span> <span>ПК диагональный сплит из 3 видео</span>
@@ -224,6 +330,15 @@ function VideoManager({
<span>Телефон только центральное видео</span> <span>Телефон только центральное видео</span>
</div> </div>
</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> </div>
); );
} }
@@ -240,25 +355,19 @@ export default function HeroEditorPage() {
<InputField <InputField
label="Заголовок" label="Заголовок"
value={data.headline} value={data.headline || ""}
onChange={(v) => update({ ...data, headline: v })} onChange={(v) => update({ ...data, headline: v })}
/> />
<InputField <InputField
label="Подзаголовок" label="Подзаголовок"
value={data.subheadline} value={data.subheadline || ""}
onChange={(v) => update({ ...data, subheadline: v })} onChange={(v) => update({ ...data, subheadline: v })}
/> />
<InputField <InputField
label="Текст кнопки" label="Текст кнопки"
value={data.ctaText} value={data.ctaText || ""}
onChange={(v) => update({ ...data, ctaText: v })} onChange={(v) => update({ ...data, ctaText: v })}
/> />
<InputField
label="Ссылка кнопки"
value={data.ctaHref}
onChange={(v) => update({ ...data, ctaHref: v })}
type="url"
/>
</> </>
)} )}
</SectionEditor> </SectionEditor>
+36 -22
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import { import {
LayoutDashboard, LayoutDashboard,
Sparkles, Sparkles,
@@ -23,22 +24,25 @@ import {
ChevronLeft, ChevronLeft,
ClipboardList, ClipboardList,
DoorOpen, DoorOpen,
MessageSquare,
} from "lucide-react"; } from "lucide-react";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: "/admin", label: "Дашборд", icon: LayoutDashboard }, { href: "/admin", label: "Дашборд", icon: LayoutDashboard },
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe }, { 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/hero", label: "Главный экран", icon: Sparkles },
{ href: "/admin/about", label: "О студии", icon: FileText }, { href: "/admin/about", label: "О студии", icon: FileText },
{ href: "/admin/team", label: "Команда", icon: Users },
{ href: "/admin/classes", label: "Направления", icon: BookOpen }, { 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/open-day", label: "День открытых дверей", icon: DoorOpen },
{ href: "/admin/schedule", label: "Расписание", icon: Calendar }, { href: "/admin/schedule", label: "Расписание", icon: Calendar },
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
{ href: "/admin/pricing", label: "Цены", icon: DollarSign }, { 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/news", label: "Новости", icon: Newspaper },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
{ href: "/admin/contact", label: "Контакты", icon: Phone }, { href: "/admin/contact", label: "Контакты", icon: Phone },
]; ];
@@ -53,17 +57,19 @@ export default function AdminLayout({
const [unreadTotal, setUnreadTotal] = useState(0); const [unreadTotal, setUnreadTotal] = useState(0);
const isLoginPage = pathname === "/admin/login"; const isLoginPage = pathname === "/admin/login";
// Fetch unread counts — poll every 10s // Fetch unread counts — poll every 10s, stop after 3 consecutive failures
useEffect(() => { useEffect(() => {
if (isLoginPage) return; if (isLoginPage) return;
let failures = 0;
let interval: ReturnType<typeof setInterval>;
function fetchCounts() { function fetchCounts() {
adminFetch("/api/admin/unread-counts") adminFetch("/api/admin/unread-counts")
.then((r) => r.json()) .then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data: { total: number }) => setUnreadTotal(data.total)) .then((data: { total: number }) => { setUnreadTotal(data.total); failures = 0; })
.catch(() => {}); .catch(() => { failures++; if (failures >= 3 && interval) clearInterval(interval); });
} }
fetchCounts(); fetchCounts();
const interval = setInterval(fetchCounts, 10000); interval = setInterval(fetchCounts, 10000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isLoginPage]); }, [isLoginPage]);
@@ -83,7 +89,7 @@ export default function AdminLayout({
} }
return ( 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 */} {/* Mobile overlay */}
{sidebarOpen && ( {sidebarOpen && (
<div <div
@@ -94,23 +100,24 @@ export default function AdminLayout({
{/* Sidebar */} {/* Sidebar */}
<aside <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" 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"> <Link href="/admin" className="text-lg font-bold">
BLACK HEART BLACK HEART
</Link> </Link>
<button <button
onClick={() => setSidebarOpen(false)} 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} /> <X size={20} />
</button> </button>
</div> </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) => { {NAV_ITEMS.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const active = isActive(item.href); 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 ${ className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors ${
active active
? "bg-gold/10 text-gold font-medium" ? "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} /> <Icon size={18} />
{item.label} {item.label}
{item.href === "/admin/bookings" && unreadTotal > 0 && ( {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} {unreadTotal > 99 ? "99+" : unreadTotal}
</span> </span>
)} )}
@@ -137,18 +144,22 @@ export default function AdminLayout({
})} })}
</nav> </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 <Link
href="/" href="/"
target="_blank" 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} /> <ChevronLeft size={18} />
Открыть сайт Открыть сайт
</Link> </Link>
<button <button
onClick={handleLogout} 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} /> <LogOut size={18} />
Выйти Выйти
@@ -159,14 +170,17 @@ export default function AdminLayout({
{/* Main content */} {/* Main content */}
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
{/* Top bar (mobile) */} {/* 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 <button
onClick={() => setSidebarOpen(true)} 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} /> <Menu size={24} />
</button> </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> </header>
<main className="flex-1 p-4 sm:p-6 lg:p-8">{children}</main> <main className="flex-1 p-4 sm:p-6 lg:p-8">{children}</main>
+22 -8
View File
@@ -2,11 +2,13 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
export default function AdminLoginPage() { export default function AdminLoginPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const router = useRouter(); const router = useRouter();
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
@@ -34,33 +36,45 @@ export default function AdminLoginPage() {
} }
return ( return (
<div className="flex min-h-screen items-center justify-center bg-neutral-950 px-4"> <div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4 dark:bg-neutral-950">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="w-full max-w-sm space-y-6 rounded-2xl border border-white/10 bg-neutral-900 p-8" className="w-full max-w-sm space-y-6 rounded-2xl border border-neutral-200 bg-white p-8 dark:border-white/10 dark:bg-neutral-900"
> >
<div className="text-center"> <div className="text-center">
<h1 className="text-2xl font-bold text-white">BLACK HEART</h1> <h1 className="text-2xl font-bold text-neutral-900 dark:text-white">BLACK HEART</h1>
<p className="mt-1 text-sm text-neutral-400">Панель управления</p> <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Панель управления</p>
</div> </div>
<div> <div>
<label htmlFor="password" className="block text-sm text-neutral-400 mb-2"> <label htmlFor="password" className="block text-sm text-neutral-500 mb-2 dark:text-neutral-400">
Пароль Пароль
</label> </label>
<div className="relative">
<input <input
id="password" id="password"
type="password" type={showPassword ? "text" : "password"}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-3 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors" className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-3 pr-11 text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
placeholder="Введите пароль" placeholder="Введите пароль"
autoFocus autoFocus
aria-describedby={error ? "login-error" : undefined}
/> />
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? "Скрыть пароль" : "Показать пароль"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
tabIndex={-1}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div> </div>
{error && ( {error && (
<p className="text-sm text-red-400 text-center">{error}</p> <p id="login-error" role="alert" className="text-sm text-red-400 text-center">{error}</p>
)} )}
<button <button
+255 -298
View File
@@ -1,178 +1,49 @@
"use client"; "use client";
import { useState, useRef, useEffect, useMemo } from "react"; import { useState, useEffect } from "react";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField, ParticipantLimits } from "../_components/FormField"; import { InputField, TextareaField, RichTextarea, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
import { ImageCropField } from "../_components/ImageCropField";
import { ArrayEditor } from "../_components/ArrayEditor"; import { ArrayEditor } from "../_components/ArrayEditor";
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react"; import { PriceField } from "../_components/PriceField";
import { Plus, X, Loader2, AlertCircle, Check, Search } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import type { MasterClassItem, MasterClassSlot } from "@/types/content"; import type { MasterClassItem, MasterClassSlot } from "@/types/content";
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) { // --- Helpers ---
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
function isItemArchived(item: MasterClassItem): boolean {
const slots = item.slots ?? [];
if (slots.length === 0) return false;
const today = new Date().toISOString().slice(0, 10);
return slots.every((s) => s.date && s.date < today);
}
function itemMatchesSearch(item: MasterClassItem, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
return ( return (
<div> (item.title || "").toLowerCase().includes(q) ||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label> (item.trainer || "").toLowerCase().includes(q)
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
<input
type="text"
value={raw}
onChange={(e) => {
const v = e.target.value;
onChange(v ? `${v} BYN` : "");
}}
placeholder={placeholder ?? "0"}
className="flex-1 bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none min-w-0"
/>
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
BYN
</span>
</div>
</div>
); );
} }
function itemMatchesDateFilter(item: MasterClassItem, filter: "all" | "upcoming" | "past"): boolean {
if (filter === "all") return true;
const archived = isItemArchived(item);
return filter === "past" ? archived : !archived;
}
function itemMatchesLocation(item: MasterClassItem, locationFilter: string): boolean {
if (!locationFilter) return true;
return (item.location || "") === locationFilter;
}
interface MasterClassesData { interface MasterClassesData {
title: string; title: string;
successMessage?: string;
waitingListText?: string;
items: MasterClassItem[]; items: MasterClassItem[];
} }
// --- Autocomplete Multi-Select ---
function AutocompleteMulti({
label,
value,
onChange,
options,
placeholder,
}: {
label: string;
value: string;
onChange: (v: string) => void;
options: string[];
placeholder?: string;
}) {
const selected = useMemo(() => (value ? value.split(", ").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();
}
function removeItem(item: string) {
onChange(selected.filter((s) => s !== item).join(", "));
}
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());
}
}
if (e.key === "Backspace" && !query && selected.length > 0) {
removeItem(selected[selected.length - 1]);
}
if (e.key === "Escape") {
setOpen(false);
setQuery("");
}
}
return (
<div ref={containerRef} className="relative">
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
{/* Selected chips + input */}
<div
onClick={() => {
setOpen(true);
inputRef.current?.focus();
}}
className={`flex flex-wrap items-center gap-1.5 rounded-lg border bg-neutral-800 px-3 py-2 min-h-[42px] cursor-text transition-colors ${
open ? "border-gold" : "border-white/10"
}`}
>
{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>
</span>
))}
<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-white placeholder-neutral-500 outline-none"
/>
</div>
{/* Dropdown */}
{open && filtered.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden max-h-48 overflow-y-auto">
{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-white hover:bg-white/5 transition-colors"
>
{opt}
</button>
))}
</div>
)}
</div>
);
}
// --- Location Select --- // --- Location Select ---
function LocationSelect({ function LocationSelect({
value, value,
@@ -197,7 +68,7 @@ function LocationSelect({
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${ className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
active active
? "bg-gold/20 text-gold border border-gold/40" ? "bg-gold/20 text-gold border border-gold/40"
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white" : "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:border-white/25 dark:hover:text-white"
}`} }`}
> >
{active && <Check size={10} className="inline mr-1" />} {active && <Check size={10} className="inline mr-1" />}
@@ -227,6 +98,13 @@ function calcDurationText(startTime: string, endTime: string): string {
return `${m} мин`; return `${m} мин`;
} }
function hasTimeError(startTime: string, endTime: string): boolean {
if (!startTime || !endTime) return false;
const [sh, sm] = startTime.split(":").map(Number);
const [eh, em] = endTime.split(":").map(Number);
return (eh * 60 + em) <= (sh * 60 + sm);
}
function SlotsField({ function SlotsField({
slots, slots,
onChange, onChange,
@@ -258,31 +136,37 @@ function SlotsField({
<div className="space-y-2"> <div className="space-y-2">
{slots.map((slot, i) => { {slots.map((slot, i) => {
const dur = calcDurationText(slot.startTime, slot.endTime); const dur = calcDurationText(slot.startTime, slot.endTime);
const timeError = hasTimeError(slot.startTime, slot.endTime);
return ( return (
<div key={i} className="flex items-center gap-2 flex-wrap"> <div key={i}>
<div className="flex items-center gap-2 flex-wrap">
<input <input
type="date" type="date"
value={slot.date} value={slot.date}
onChange={(e) => updateSlot(i, { date: e.target.value })} onChange={(e) => updateSlot(i, { date: e.target.value })}
className={`w-[140px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${ className={`w-[140px] rounded-lg border bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
!slot.date ? "border-red-500/50" : "border-white/10 focus:border-gold" !slot.date ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
}`} }`}
/> />
<input <input
type="time" type="time"
value={slot.startTime} value={slot.startTime}
onChange={(e) => updateSlot(i, { startTime: e.target.value })} onChange={(e) => updateSlot(i, { startTime: e.target.value })}
className="w-[100px] rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white outline-none focus:border-gold transition-colors [color-scheme:dark]" className={`w-[100px] rounded-lg border bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
timeError ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
}`}
/> />
<span className="text-neutral-500 text-xs"></span> <span className="text-neutral-500 text-xs">&ndash;</span>
<input <input
type="time" type="time"
value={slot.endTime} value={slot.endTime}
onChange={(e) => updateSlot(i, { endTime: e.target.value })} onChange={(e) => updateSlot(i, { endTime: e.target.value })}
className="w-[100px] rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white outline-none focus:border-gold transition-colors [color-scheme:dark]" className={`w-[100px] rounded-lg border bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
timeError ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
}`}
/> />
{dur && ( {dur && (
<span className="text-[11px] text-neutral-500 bg-neutral-800/50 rounded-full px-2 py-0.5"> <span className="text-[11px] text-neutral-500 bg-neutral-200/50 rounded-full px-2 py-0.5 dark:bg-neutral-800/50">
{dur} {dur}
</span> </span>
)} )}
@@ -294,12 +178,19 @@ function SlotsField({
<X size={14} /> <X size={14} />
</button> </button>
</div> </div>
{!slot.date && (
<p className="mt-0.5 ml-1 text-[11px] text-red-400">Укажите дату</p>
)}
{timeError && (
<p className="mt-0.5 ml-1 text-[11px] text-red-400">Время окончания должно быть позже начала</p>
)}
</div>
); );
})} })}
<button <button
type="button" type="button"
onClick={addSlot} onClick={addSlot}
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-3 py-1.5 text-xs text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors" className="flex items-center gap-2 rounded-lg border border-dashed border-neutral-200 bg-neutral-100/50 px-3 py-1.5 text-xs text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors dark:border-white/10 dark:bg-neutral-800/50"
> >
<Plus size={12} /> <Plus size={12} />
Добавить дату Добавить дату
@@ -309,93 +200,7 @@ function SlotsField({
); );
} }
// --- Image Upload --- // PhotoPreview replaced by shared ImageCropField
function ImageUploadField({
value,
onChange,
}: {
value: string;
onChange: (path: string) => void;
}) {
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append("file", file);
formData.append("folder", "master-classes");
try {
const res = await adminFetch("/api/admin/upload", {
method: "POST",
body: formData,
});
const result = await res.json();
if (result.path) onChange(result.path);
} catch {
/* upload failed */
} finally {
setUploading(false);
}
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">
Изображение
</label>
{value ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 rounded-lg bg-neutral-700/50 px-3 py-2 text-sm text-neutral-300">
<ImageIcon size={14} className="text-gold" />
<span className="max-w-[200px] truncate">
{value.split("/").pop()}
</span>
</div>
<button
type="button"
onClick={() => onChange("")}
className="rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
</button>
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
{uploading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Upload size={14} />
)}
Заменить
<input
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label>
</div>
) : (
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-3 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
{uploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Upload size={16} />
)}
{uploading ? "Загрузка..." : "Загрузить изображение"}
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label>
)}
</div>
);
}
// --- Instagram Link Field --- // --- Instagram Link Field ---
function InstagramLinkField({ function InstagramLinkField({
@@ -418,8 +223,8 @@ function InstagramLinkField({
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder="https://instagram.com/p/... или /reel/..." placeholder="https://instagram.com/p/... или /reel/..."
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${ 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 ${
error ? "border-red-500/50" : "border-white/10 focus:border-gold" error ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
}`} }`}
/> />
{value && !error && ( {value && !error && (
@@ -474,11 +279,113 @@ function ValidationHint({ fields }: { fields: Record<string, string> }) {
); );
} }
// --- Filter bar ---
type DateFilter = "all" | "upcoming" | "past";
const DATE_FILTER_LABELS: Record<DateFilter, string> = {
all: "Все",
upcoming: "Предстоящие",
past: "Прошедшие",
};
function FilterBar({
search,
onSearchChange,
dateFilter,
onDateFilterChange,
locationFilter,
onLocationFilterChange,
locations,
totalCount,
visibleCount,
}: {
search: string;
onSearchChange: (v: string) => void;
dateFilter: DateFilter;
onDateFilterChange: (v: DateFilter) => void;
locationFilter: string;
onLocationFilterChange: (v: string) => void;
locations: { name: string; address: string }[];
totalCount: number;
visibleCount: number;
}) {
return (
<div className="space-y-3 mb-4">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Поиск по названию или тренеру..."
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 pl-10 pr-4 py-2.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
/>
{search && (
<button
type="button"
onClick={() => onSearchChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
>
<X size={14} />
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex gap-1">
{(Object.keys(DATE_FILTER_LABELS) as DateFilter[]).map((key) => (
<button
key={key}
type="button"
onClick={() => onDateFilterChange(key)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
dateFilter === key
? "bg-gold/20 text-gold border border-gold/40"
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:border-white/25 dark:hover:text-white"
}`}
>
{DATE_FILTER_LABELS[key]}
</button>
))}
</div>
{locations.length > 0 && (
<>
<span className="text-neutral-600 text-xs">|</span>
<div className="flex gap-1">
{locations.map((loc) => (
<button
key={loc.name}
type="button"
onClick={() => onLocationFilterChange(locationFilter === loc.name ? "" : loc.name)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
locationFilter === loc.name
? "bg-gold/20 text-gold border border-gold/40"
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:border-white/25 dark:hover:text-white"
}`}
>
{loc.name}
</button>
))}
</div>
</>
)}
{visibleCount < totalCount && (
<span className="text-xs text-neutral-500 ml-auto">
{visibleCount} из {totalCount}
</span>
)}
</div>
</div>
);
}
// --- Main page --- // --- Main page ---
export default function MasterClassesEditorPage() { export default function MasterClassesEditorPage() {
const [trainers, setTrainers] = useState<string[]>([]); const [trainers, setTrainers] = useState<string[]>([]);
const [styles, setStyles] = useState<string[]>([]); const [styles, setStyles] = useState<string[]>([]);
const [locations, setLocations] = useState<{ name: string; address: string }[]>([]); const [locations, setLocations] = useState<{ name: string; address: string }[]>([]);
const [search, setSearch] = useState("");
const [dateFilter, setDateFilter] = useState<DateFilter>("all");
const [locationFilter, setLocationFilter] = useState("");
useEffect(() => { useEffect(() => {
// Fetch trainers from team // Fetch trainers from team
@@ -510,8 +417,31 @@ export default function MasterClassesEditorPage() {
<SectionEditor<MasterClassesData> <SectionEditor<MasterClassesData>
sectionKey="masterClasses" sectionKey="masterClasses"
title="Мастер-классы" title="Мастер-классы"
defaultData={{ items: [] }}
> >
{(data, update) => ( {(data, update) => {
// Sort: active first, archived at bottom
const displayItems = [...data.items].sort((a, b) => {
const aArch = isItemArchived(a);
const bArch = isItemArchived(b);
if (aArch === bArch) return 0;
return aArch ? 1 : -1;
});
const hiddenItems = new Set<number>();
displayItems.forEach((item, i) => {
if (
!itemMatchesSearch(item, search) ||
!itemMatchesDateFilter(item, dateFilter) ||
!itemMatchesLocation(item, locationFilter)
) {
hiddenItems.add(i);
}
});
const visibleCount = data.items.length - hiddenItems.size;
return (
<> <>
<InputField <InputField
label="Заголовок секции" label="Заголовок секции"
@@ -519,27 +449,42 @@ export default function MasterClassesEditorPage() {
onChange={(v) => update({ ...data, title: v })} onChange={(v) => update({ ...data, title: v })}
/> />
<InputField <FilterBar
label="Текст после записи (success popup)" search={search}
value={data.successMessage || ""} onSearchChange={setSearch}
onChange={(v) => update({ ...data, successMessage: v || undefined })} dateFilter={dateFilter}
placeholder="Вы записаны! Мы свяжемся с вами" onDateFilterChange={setDateFilter}
/> locationFilter={locationFilter}
onLocationFilterChange={setLocationFilter}
<TextareaField locations={locations}
label="Текст для листа ожидания" totalCount={data.items.length}
value={data.waitingListText || ""} visibleCount={visibleCount}
onChange={(v) => update({ ...data, waitingListText: v || undefined })}
placeholder="Все места заняты, но мы добавили вас в лист ожидания..."
rows={2}
/> />
<ArrayEditor <ArrayEditor
label="Мастер-классы" label="Мастер-классы"
items={data.items} items={displayItems}
onChange={(items) => update({ ...data, items })} onChange={(items) => update({ ...data, items })}
renderItem={(item, _i, updateItem) => ( collapsible
<div className="space-y-3"> hiddenItems={hiddenItems}
getItemTitle={(item) => {
const base = item.location
? `${item.title || "Без названия"} · ${item.location}`
: item.title || "Без названия";
return base;
}}
getItemBadge={(item) =>
isItemArchived(item) ? (
<span className="shrink-0 rounded-full bg-neutral-700/50 px-2 py-0.5 text-[10px] font-medium text-neutral-500">
Архив
</span>
) : null
}
renderItem={(item, _i, updateItem) => {
const archived = isItemArchived(item);
return (
<div className={`space-y-3 ${archived ? "opacity-50" : ""}`}>
<ValidationHint <ValidationHint
fields={{ fields={{
Название: item.title, Название: item.title,
@@ -557,11 +502,22 @@ export default function MasterClassesEditorPage() {
placeholder="Мастер-класс от Анны Тарыбы" placeholder="Мастер-класс от Анны Тарыбы"
/> />
<ImageUploadField {/* Photo + key fields side by side */}
value={item.image} <div className="flex gap-5 items-center">
onChange={(v) => updateItem({ ...item, image: v })} <div className="w-[220px] shrink-0">
<ImageCropField
image={item.image || ""}
focalX={item.imageFocalX ?? 50}
focalY={item.imageFocalY ?? 50}
zoom={item.imageZoom ?? 1}
folder="master-classes"
label="Фото"
aspect="aspect-[2/3]"
maxWidth="max-w-[220px]"
onChange={(d) => updateItem({ ...item, image: d.image, imageFocalX: d.focalX, imageFocalY: d.focalY, imageZoom: d.zoom })}
/> />
</div>
<div className="flex-1 space-y-3 min-w-0">
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<AutocompleteMulti <AutocompleteMulti
label="Тренер" label="Тренер"
@@ -578,14 +534,13 @@ export default function MasterClassesEditorPage() {
placeholder="Добавить стиль..." placeholder="Добавить стиль..."
/> />
</div> </div>
<div className="grid gap-3 sm:grid-cols-2">
<PriceField <PriceField
label="Стоимость" label="Стоимость"
value={item.cost} value={item.cost}
onChange={(v) => updateItem({ ...item, cost: v })} onChange={(v) => updateItem({ ...item, cost: v })}
placeholder="40" placeholder="40"
/> />
{locations.length > 0 && ( {locations.length > 0 && (
<LocationSelect <LocationSelect
value={item.location || ""} value={item.location || ""}
@@ -595,13 +550,28 @@ export default function MasterClassesEditorPage() {
locations={locations} locations={locations}
/> />
)} )}
</div>
<InstagramLinkField
value={item.instagramUrl || ""}
onChange={(v) =>
updateItem({ ...item, instagramUrl: v || undefined })
}
/>
<ParticipantLimits
min={item.minParticipants ?? 0}
max={item.maxParticipants ?? 0}
onMinChange={(v) => updateItem({ ...item, minParticipants: v })}
onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })}
/>
</div>
</div>
<SlotsField <SlotsField
slots={item.slots ?? []} slots={item.slots ?? []}
onChange={(slots) => updateItem({ ...item, slots })} onChange={(slots) => updateItem({ ...item, slots })}
/> />
<TextareaField <RichTextarea
label="Описание" label="Описание"
value={item.description || ""} value={item.description || ""}
onChange={(v) => onChange={(v) =>
@@ -610,23 +580,9 @@ export default function MasterClassesEditorPage() {
placeholder="Описание мастер-класса, трек, стиль..." placeholder="Описание мастер-класса, трек, стиль..."
rows={3} rows={3}
/> />
<InstagramLinkField
value={item.instagramUrl || ""}
onChange={(v) =>
updateItem({ ...item, instagramUrl: v || undefined })
}
/>
<ParticipantLimits
min={item.minParticipants ?? 0}
max={item.maxParticipants ?? 0}
onMinChange={(v) => updateItem({ ...item, minParticipants: v })}
onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })}
/>
</div> </div>
)} );
}}
createItem={() => ({ createItem={() => ({
title: "", title: "",
image: "", image: "",
@@ -638,7 +594,8 @@ export default function MasterClassesEditorPage() {
addLabel="Добавить мастер-класс" addLabel="Добавить мастер-класс"
/> />
</> </>
)} );
}}
</SectionEditor> </SectionEditor>
); );
} }
+55 -110
View File
@@ -1,11 +1,10 @@
"use client"; "use client";
import { useState, useRef } from "react";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField"; import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor"; import { ArrayEditor } from "../_components/ArrayEditor";
import { Upload, Loader2, ImageIcon, X } from "lucide-react"; import { ImageCropField } from "../_components/ImageCropField";
import { adminFetch } from "@/lib/csrf"; import { AlertCircle } from "lucide-react";
import type { NewsItem } from "@/types/content"; import type { NewsItem } from "@/types/content";
interface NewsData { interface NewsData {
@@ -13,96 +12,9 @@ interface NewsData {
items: NewsItem[]; items: NewsItem[];
} }
function ImageUploadField({
value,
onChange,
}: {
value: string;
onChange: (path: string) => void;
}) {
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append("file", file);
formData.append("folder", "news");
try {
const res = await adminFetch("/api/admin/upload", {
method: "POST",
body: formData,
});
const result = await res.json();
if (result.path) onChange(result.path);
} catch {
/* upload failed */
} finally {
setUploading(false);
}
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">
Изображение
</label>
{value ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 rounded-lg bg-neutral-700/50 px-3 py-2 text-sm text-neutral-300">
<ImageIcon size={14} className="text-gold" />
<span className="max-w-[200px] truncate">
{value.split("/").pop()}
</span>
</div>
<button
type="button"
onClick={() => onChange("")}
className="rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
</button>
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
{uploading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Upload size={14} />
)}
Заменить
<input
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label>
</div>
) : (
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-3 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
{uploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Upload size={16} />
)}
{uploading ? "Загрузка..." : "Загрузить изображение"}
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label>
)}
</div>
);
}
export default function NewsEditorPage() { export default function NewsEditorPage() {
return ( return (
<SectionEditor<NewsData> sectionKey="news" title="Новости"> <SectionEditor<NewsData> sectionKey="news" title="Новости" defaultData={{ items: [] }}>
{(data, update) => ( {(data, update) => (
<> <>
<InputField <InputField
@@ -115,33 +27,65 @@ export default function NewsEditorPage() {
label="Новости" label="Новости"
items={data.items} items={data.items}
onChange={(items) => update({ ...data, items })} onChange={(items) => update({ ...data, items })}
renderItem={(item, _i, updateItem) => ( collapsible
getItemTitle={(item) => {
const title = item.title || "Без заголовка";
if (item.date) {
try {
const d = new Date(item.date);
const date = d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
const time = item.date.includes("T") ? ` ${d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}` : "";
return `${title} · ${date}${time}`;
} catch { /* ignore */ }
}
return title;
}}
getItemBadge={(item) => {
const missing = [
!item.title.trim() && "заголовок",
!item.text.trim() && "текст",
!item.image && "фото",
].filter(Boolean);
if (missing.length === 0) return null;
return (
<span className="shrink-0 rounded-full bg-red-500/10 border border-red-500/20 px-2 py-0.5 text-[10px] font-medium text-red-400">
Черновик
</span>
);
}}
renderItem={(item, _i, updateItem) => {
const missing = [
!item.title.trim() && "Заголовок",
!item.text.trim() && "Текст",
!item.image && "Изображение",
].filter(Boolean);
return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2"> {missing.length > 0 && (
<div className="flex items-start gap-1.5 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-xs text-red-400">
<AlertCircle size={12} className="shrink-0 mt-0.5" />
<span>Не опубликовано не заполнено: {missing.join(", ")}</span>
</div>
)}
<InputField <InputField
label="Заголовок" label="Заголовок"
value={item.title} value={item.title}
onChange={(v) => updateItem({ ...item, title: v })} onChange={(v) => updateItem({ ...item, title: v })}
/> />
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label>
<input
type="date"
value={item.date}
onChange={(e) => updateItem({ ...item, date: e.target.value })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
/>
</div>
</div>
<TextareaField <TextareaField
label="Текст" label="Текст"
value={item.text} value={item.text}
onChange={(v) => updateItem({ ...item, text: v })} onChange={(v) => updateItem({ ...item, text: v })}
/> />
<div className="grid gap-3 sm:grid-cols-2"> <ImageCropField
<ImageUploadField image={item.image || ""}
value={item.image || ""} focalX={item.imageFocalX ?? 50}
onChange={(v) => updateItem({ ...item, image: v || undefined })} focalY={item.imageFocalY ?? 50}
zoom={item.imageZoom ?? 1}
folder="news"
aspect="aspect-[21/9]"
label="Изображение"
onChange={(d) => updateItem({ ...item, image: d.image || undefined, imageFocalX: d.focalX, imageFocalY: d.focalY, imageZoom: d.zoom })}
/> />
<InputField <InputField
label="Ссылка (необязательно)" label="Ссылка (необязательно)"
@@ -150,14 +94,15 @@ export default function NewsEditorPage() {
placeholder="https://instagram.com/p/..." placeholder="https://instagram.com/p/..."
/> />
</div> </div>
</div> );
)} }}
createItem={(): NewsItem => ({ createItem={(): NewsItem => ({
title: "", title: "",
text: "", text: "",
date: new Date().toISOString().slice(0, 10), date: new Date().toISOString(),
})} })}
addLabel="Добавить новость" addLabel="Добавить новость"
addPosition="top"
/> />
</> </>
)} )}

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