Compare commits
58 Commits
4d90785c5b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1571b63ec3 | |||
| 8c84da279e | |||
| 03d3cad0a7 | |||
| 89f132634d | |||
| b7eacce479 | |||
| a832af9344 | |||
| b738976111 | |||
| a00fdaa760 | |||
| 9f86bcbce9 | |||
| 28afcc18bc | |||
| b9510213d7 | |||
| bac46aeb34 | |||
| 3621503470 | |||
| a080ef5a8e | |||
| 97663c514e | |||
| 0e626451e7 | |||
| a587736dd3 | |||
| bbe485d8fc | |||
| 16ac56f62e | |||
| fa26092ea4 | |||
| bf9b1876b5 | |||
| 3c0be33b1c | |||
| 06f7dcf570 | |||
| 2c6bee9eb1 | |||
| 2d13b82507 | |||
| ae30be8f9d | |||
| 06be6b48ce | |||
| 22bd117dae | |||
| e56a6a1608 | |||
| 77ad2a6b68 | |||
| 024424c578 | |||
| bdeedcfcc8 | |||
| b322c969f2 | |||
| a69c08482f | |||
| d5541a8bc9 | |||
| 035f68776a | |||
| c4c3a7ab0d | |||
| 76307e298b | |||
| ec08f8e8d5 | |||
| a769ea844d | |||
| 8088b99a43 | |||
| 228e547e10 | |||
| c9303e5aad | |||
| c9cfe63837 | |||
| f65a6ed811 | |||
| 09b2f40090 | |||
| 4c8c6eb0d2 | |||
| 4b6443c867 | |||
| bc0f23df34 | |||
| ad1715acb8 | |||
| 30398d2aeb | |||
| 95c33391e5 | |||
| 64e923460f | |||
| 6c485872b0 | |||
| 983bf296fc | |||
| 4805c3b9ea | |||
| 24d48a9409 | |||
| e4cb38c409 |
@@ -7,7 +7,7 @@ Content language: Russian
|
||||
|
||||
## Tech Stack
|
||||
- **Next.js 16** (App Router, TypeScript, Turbopack)
|
||||
- **Tailwind CSS v4** (dark mode only, gold/black theme)
|
||||
- **Tailwind CSS v4** (dual theme: dark default + light, gold accent)
|
||||
- **lucide-react** for icons
|
||||
- **better-sqlite3** for SQLite database
|
||||
- **Fonts**: Inter (body) + Oswald (headings) via `next/font`
|
||||
@@ -25,7 +25,7 @@ Content language: Russian
|
||||
src/
|
||||
├── app/
|
||||
│ ├── layout.tsx # Root layout, fonts, metadata
|
||||
│ ├── page.tsx # Landing: Hero → [OpenDay] → About → Team → Classes → MasterClasses → Schedule → Pricing → News → FAQ → Contact
|
||||
│ ├── page.tsx # Landing: Hero → About → Classes → Team → [OpenDay] → Schedule → Pricing → MasterClasses → News → FAQ → Contact
|
||||
│ ├── globals.css # Tailwind imports
|
||||
│ ├── styles/
|
||||
│ │ ├── theme.css # Theme variables, semantic classes
|
||||
@@ -111,8 +111,9 @@ src/
|
||||
|
||||
## Brand / Styling
|
||||
- **Accent**: gold (`#c9a96e` / `hsl(37, 42%, 61%)`)
|
||||
- **Background**: `#050505` – `#0a0a0a` (dark only)
|
||||
- **Surface**: `#171717` dark cards
|
||||
- **Dark theme** (default): background `#050505`–`#0a0a0a`, surface `#171717`, text `neutral-100`
|
||||
- **Light theme**: background `white`/`neutral-50`, surface `white`, text `neutral-900`
|
||||
- Theme toggle via `ThemeToggle` component, `.dark` class on `<html>`, stored in `localStorage`
|
||||
- Logo: transparent PNG heart with gold glow, uses `unoptimized`
|
||||
|
||||
## Content Data
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||
...(process.env.NODE_ENV === "production"
|
||||
? [{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["better-sqlite3"],
|
||||
allowedDevOrigins: [
|
||||
"black-heart.dolgolyov-family.by",
|
||||
"192.168.2.56",
|
||||
],
|
||||
headers: async () => [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev -H 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
|
||||
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 481 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 27 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 579 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 51 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Plus, Trash2, GripVertical } from "lucide-react";
|
||||
import { Plus, Trash2, GripVertical, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
let nextItemId = 1;
|
||||
|
||||
interface ArrayEditorProps<T> {
|
||||
items: T[];
|
||||
@@ -11,16 +14,33 @@ interface ArrayEditorProps<T> {
|
||||
createItem: () => T;
|
||||
label?: string;
|
||||
addLabel?: string;
|
||||
collapsible?: boolean;
|
||||
getItemTitle?: (item: T, index: number) => string;
|
||||
getItemBadge?: (item: T, index: number) => React.ReactNode;
|
||||
hiddenItems?: Set<number>;
|
||||
addPosition?: "top" | "bottom";
|
||||
/** Render grip + content + delete on a single row (compact mode) */
|
||||
inline?: boolean;
|
||||
/** Hide the add button (when parent manages adding) */
|
||||
hideAdd?: boolean;
|
||||
}
|
||||
|
||||
export function ArrayEditor<T>({
|
||||
items,
|
||||
items = [] as unknown as T[],
|
||||
onChange,
|
||||
renderItem,
|
||||
createItem,
|
||||
label,
|
||||
addLabel = "Добавить",
|
||||
collapsible = false,
|
||||
getItemTitle,
|
||||
getItemBadge,
|
||||
hiddenItems,
|
||||
addPosition = "bottom",
|
||||
inline = false,
|
||||
hideAdd = false,
|
||||
}: ArrayEditorProps<T>) {
|
||||
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [insertAt, setInsertAt] = useState<number | null>(null);
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
@@ -29,6 +49,30 @@ export function ArrayEditor<T>({
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [newItemIndex, setNewItemIndex] = useState<number | null>(null);
|
||||
const [droppedIndex, setDroppedIndex] = useState<number | null>(null);
|
||||
const [collapsed, setCollapsed] = useState<Set<number>>(() => collapsible ? new Set(items.map((_, i) => i)) : new Set());
|
||||
|
||||
// Stable keys for items — avoids index-as-key issues during reorder
|
||||
const stableKeysRef = useRef<number[]>([]);
|
||||
if (stableKeysRef.current.length < items.length) {
|
||||
while (stableKeysRef.current.length < items.length) {
|
||||
stableKeysRef.current.push(nextItemId++);
|
||||
}
|
||||
} else if (stableKeysRef.current.length > items.length) {
|
||||
stableKeysRef.current = stableKeysRef.current.slice(0, items.length);
|
||||
}
|
||||
function getStableKey(index: number): number {
|
||||
return stableKeysRef.current[index];
|
||||
}
|
||||
|
||||
function toggleCollapse(index: number) {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
@@ -47,6 +91,7 @@ export function ArrayEditor<T>({
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
stableKeysRef.current.splice(index, 1);
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
@@ -113,7 +158,14 @@ export function ArrayEditor<T>({
|
||||
const updated = [...items];
|
||||
const [moved] = updated.splice(capturedDrag, 1);
|
||||
updated.splice(targetIndex, 0, moved);
|
||||
// Sync stable keys
|
||||
const keys = [...stableKeysRef.current];
|
||||
const [movedKey] = keys.splice(capturedDrag, 1);
|
||||
keys.splice(targetIndex, 0, movedKey);
|
||||
stableKeysRef.current = keys;
|
||||
onChange(updated);
|
||||
setDroppedIndex(targetIndex);
|
||||
setTimeout(() => setDroppedIndex(null), 1500);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -130,32 +182,96 @@ export function ArrayEditor<T>({
|
||||
|
||||
function renderList() {
|
||||
if (dragIndex === null || insertAt === null) {
|
||||
return items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
className={`rounded-lg border bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-all ${
|
||||
newItemIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(i)}
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
return items.map((item, i) => {
|
||||
const isCollapsed = collapsible && collapsed.has(i) && newItemIndex !== i;
|
||||
const isHidden = hiddenItems?.has(i) ?? false;
|
||||
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||
return (
|
||||
<div
|
||||
key={getStableKey(i)}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
className={`rounded-lg border bg-neutral-100/80 mb-3 hover:border-neutral-300 dark:hover:border-white/25 hover:bg-neutral-200/50 focus-within:border-gold/50 focus-within:bg-neutral-200 transition-all dark:bg-neutral-900/50 dark:hover:bg-neutral-800/50 dark:focus-within:bg-neutral-800 ${
|
||||
newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-neutral-200 dark:border-white/10"
|
||||
} ${isHidden ? "hidden" : ""}`}
|
||||
>
|
||||
{inline ? (
|
||||
/* Inline: grip + content + delete on one row */
|
||||
<div className="flex items-start gap-1.5 p-1.5">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 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={() => setConfirmDelete(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 mt-1.5 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-neutral-900 transition-colors select-none dark:hover:text-white"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
aria-label="Перетащить для сортировки"
|
||||
role="button"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapse(i)}
|
||||
aria-expanded={!isCollapsed}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
|
||||
>
|
||||
<span className="text-sm font-medium text-neutral-700 truncate group-hover:text-neutral-900 transition-colors dark:text-neutral-300 dark:group-hover:text-white">{title}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{collapsible ? (
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const elements: React.ReactNode[] = [];
|
||||
@@ -175,35 +291,75 @@ export function ArrayEditor<T>({
|
||||
elements.push(
|
||||
<div
|
||||
key="placeholder"
|
||||
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
|
||||
style={{ height: dragSize.h }}
|
||||
className="rounded-lg border-2 border-dashed border-gold/40 bg-gold/5 mb-3"
|
||||
style={{ height: collapsible ? 48 : dragSize.h }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const item = items[i];
|
||||
const isCollapsed = collapsible && collapsed.has(i);
|
||||
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||
elements.push(
|
||||
<div
|
||||
key={i}
|
||||
key={getStableKey(i)}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-colors"
|
||||
className="rounded-lg border border-neutral-200 bg-neutral-100/80 mb-3 transition-colors dark:border-white/10 dark:bg-neutral-900/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
{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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(i)}
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
) : (
|
||||
<>
|
||||
<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)} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group">
|
||||
<span className="text-sm font-medium text-neutral-700 truncate group-hover:text-neutral-900 transition-colors dark:text-neutral-300 dark:group-hover:text-white">{title}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={() => removeItem(i)} aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{collapsible ? (
|
||||
<div className="grid transition-[grid-template-rows] duration-300 ease-out" style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
visualIndex++;
|
||||
@@ -213,8 +369,8 @@ export function ArrayEditor<T>({
|
||||
elements.push(
|
||||
<div
|
||||
key="placeholder"
|
||||
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
|
||||
style={{ height: dragSize.h }}
|
||||
className="rounded-lg border-2 border-dashed border-gold/40 bg-gold/5 mb-3"
|
||||
style={{ height: collapsible ? 48 : dragSize.h }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -224,22 +380,66 @@ export function ArrayEditor<T>({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3>
|
||||
{(label || (collapsible && items.length > 1)) && (
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
{label ? <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{label}</h3> : <div />}
|
||||
{collapsible && items.length > 1 && (() => {
|
||||
const allCollapsed = collapsed.size >= items.length;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => allCollapsed ? setCollapsed(new Set()) : setCollapsed(new Set(items.map((_, i) => i)))}
|
||||
className="rounded p-1 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
|
||||
title={allCollapsed ? "Развернуть все" : "Свернуть все"}
|
||||
aria-label={allCollapsed ? "Развернуть все" : "Свернуть все"}
|
||||
>
|
||||
<ChevronsUpDown size={16} className={`transition-transform duration-200 ${allCollapsed ? "" : "rotate-90"}`} />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hideAdd && addPosition === "top" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
stableKeysRef.current = [nextItemId++, ...stableKeysRef.current];
|
||||
onChange([createItem(), ...items]);
|
||||
setNewItemIndex(0);
|
||||
// Shift collapsed indices and ensure new item is expanded
|
||||
setCollapsed(prev => {
|
||||
const next = new Set<number>();
|
||||
for (const idx of prev) next.add(idx + 1);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="mb-3 flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors dark:border-white/20 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/40"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{addLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{renderList()}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onChange([...items, createItem()]); setNewItemIndex(items.length); }}
|
||||
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{addLabel}
|
||||
</button>
|
||||
{!hideAdd && addPosition === "bottom" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
stableKeysRef.current.push(nextItemId++);
|
||||
onChange([...items, createItem()]);
|
||||
setNewItemIndex(items.length);
|
||||
setCollapsed(prev => { const next = new Set(prev); next.delete(items.length); return next; });
|
||||
}}
|
||||
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors dark:border-white/20 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/40"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{addLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Floating clone following cursor */}
|
||||
{mounted && dragIndex !== null &&
|
||||
@@ -253,13 +453,21 @@ export function ArrayEditor<T>({
|
||||
height: dragSize.h,
|
||||
}}
|
||||
>
|
||||
<div className="h-full rounded-lg border-2 border-rose-500 bg-neutral-900/95 shadow-2xl shadow-rose-500/20 flex items-center gap-3 px-4">
|
||||
<GripVertical size={16} className="text-rose-400 shrink-0" />
|
||||
<span className="text-sm text-neutral-300">Перемещение элемента...</span>
|
||||
<div className="h-full rounded-lg border-2 border-gold/60 bg-white/95 shadow-2xl shadow-gold/20 flex items-center gap-3 px-4 dark:bg-neutral-900/95">
|
||||
<GripVertical size={16} className="text-gold shrink-0" />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">{collapsible && dragIndex !== null ? (getItemTitle?.(items[dragIndex], dragIndex) || "Перемещение...") : "Перемещение элемента..."}</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete !== null}
|
||||
title="Удалить элемент?"
|
||||
message="Это действие нельзя отменить."
|
||||
onConfirm={() => { if (confirmDelete !== null) removeItem(confirmDelete); setConfirmDelete(null); }}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
count?: number;
|
||||
defaultOpen?: boolean;
|
||||
isOpen?: boolean;
|
||||
onToggle?: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared collapsible section for admin pages.
|
||||
* Supports both controlled (isOpen/onToggle) and uncontrolled (defaultOpen) modes.
|
||||
*/
|
||||
export function CollapsibleSection({
|
||||
title,
|
||||
count,
|
||||
defaultOpen = true,
|
||||
isOpen: controlledOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: CollapsibleSectionProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||
const toggle = onToggle ?? (() => setInternalOpen((v) => !v));
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-neutral-200 bg-neutral-100/50 overflow-hidden dark:border-white/10 dark:bg-neutral-800/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
className="flex items-center justify-between w-full px-5 py-3.5 text-left cursor-pointer group hover:bg-neutral-100 transition-colors dark:hover:bg-white/[0.02]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 group-hover:text-neutral-900 transition-colors dark:text-neutral-200 dark:group-hover:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
{count !== undefined && (
|
||||
<span className="text-xs text-neutral-500">{count}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`text-neutral-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-5 pb-5 space-y-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRef, useEffect, useState, useMemo } from "react";
|
||||
import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
|
||||
import { useRef, useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { Plus, X, Upload, Loader2, Link, ImageIcon, AlertCircle, Bold, Italic, List, Heading2, Pencil } from "lucide-react";
|
||||
import { formatMarkup } from "@/lib/markup";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import type { RichListItem, VictoryItem } from "@/types/content";
|
||||
import type { RichListItem } from "@/types/content";
|
||||
|
||||
interface InputFieldProps {
|
||||
label: string;
|
||||
@@ -11,10 +12,10 @@ interface InputFieldProps {
|
||||
type?: "text" | "url" | "tel";
|
||||
}
|
||||
|
||||
const baseInput = "w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none hover:border-gold/30 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-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none hover:border-gold/30 focus:border-gold transition-colors";
|
||||
const dashedInput = "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 hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors";
|
||||
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({
|
||||
@@ -29,7 +30,7 @@ export function InputField({
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputCls}
|
||||
@@ -86,16 +87,20 @@ export function ParticipantLimits({
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Мин. участников</label>
|
||||
<input type="number" min={0} value={minStr} onChange={(e) => handleMin(e.target.value)}
|
||||
aria-describedby="min-hint"
|
||||
aria-invalid={minEmpty || undefined}
|
||||
className={`${inputCls} ${minEmpty ? "!border-red-500/50" : ""}`} />
|
||||
<p className={`text-[10px] mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
<p id="min-hint" className={`text-xs mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
{minEmpty ? "Поле не может быть пустым" : "Если записей меньше — занятие можно отменить"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Макс. участников</label>
|
||||
<input type="number" min={0} value={maxStr} onChange={(e) => handleMax(e.target.value)}
|
||||
aria-describedby="max-hint"
|
||||
aria-invalid={(maxEmpty || (maxLocal > 0 && minLocal > maxLocal)) || undefined}
|
||||
className={`${inputCls} ${maxEmpty || (maxLocal > 0 && minLocal > maxLocal) ? "!border-red-500/50" : ""}`} />
|
||||
<p className={`text-[10px] mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
<p id="max-hint" className={`text-xs mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
{maxEmpty ? "Поле не может быть пустым" : maxLocal > 0 && minLocal > maxLocal ? "Макс. не может быть меньше мин." : "0 = без лимита. При заполнении — лист ожидания"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -143,7 +148,7 @@ export function TextareaField({
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
@@ -153,12 +158,258 @@ export function TextareaField({
|
||||
);
|
||||
}
|
||||
|
||||
interface RichTextareaProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export function RichTextarea({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 4,
|
||||
}: RichTextareaProps) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const hasContent = Boolean(value?.trim());
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
function onResize() {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
const wrapSelection = useCallback((before: string, after: string) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
const text = value ?? "";
|
||||
const selected = text.slice(start, end);
|
||||
|
||||
// If already wrapped, unwrap
|
||||
const beforeCheck = text.slice(Math.max(0, start - before.length), start);
|
||||
const afterCheck = text.slice(end, end + after.length);
|
||||
if (beforeCheck === before && afterCheck === after) {
|
||||
const newText = text.slice(0, start - before.length) + selected + text.slice(end + after.length);
|
||||
onChange(newText);
|
||||
requestAnimationFrame(() => {
|
||||
el.selectionStart = start - before.length;
|
||||
el.selectionEnd = end - before.length;
|
||||
el.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newText = text.slice(0, start) + before + selected + after + text.slice(end);
|
||||
onChange(newText);
|
||||
requestAnimationFrame(() => {
|
||||
if (selected) {
|
||||
el.selectionStart = start + before.length;
|
||||
el.selectionEnd = end + before.length;
|
||||
} else {
|
||||
el.selectionStart = start + before.length;
|
||||
el.selectionEnd = start + before.length;
|
||||
}
|
||||
el.focus();
|
||||
});
|
||||
}, [value, onChange]);
|
||||
|
||||
const insertAtCursor = useCallback((text: string) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const start = el.selectionStart;
|
||||
const current = value ?? "";
|
||||
// Add newline before if not at start of line
|
||||
const lineStart = current.lastIndexOf("\n", start - 1) + 1;
|
||||
const prefix = start > lineStart ? "\n" : "";
|
||||
const newText = current.slice(0, start) + prefix + text + current.slice(start);
|
||||
onChange(newText);
|
||||
const cursorPos = start + prefix.length + text.length;
|
||||
requestAnimationFrame(() => {
|
||||
el.selectionStart = cursorPos;
|
||||
el.selectionEnd = cursorPos;
|
||||
el.focus();
|
||||
});
|
||||
}, [value, onChange]);
|
||||
|
||||
// Track active formatting at cursor position
|
||||
const [cursorPos, setCursorPos] = useState<{ start: number; end: number }>({ start: 0, end: 0 });
|
||||
|
||||
const updateCursorPos = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setCursorPos({ start: el.selectionStart, end: el.selectionEnd });
|
||||
}, []);
|
||||
|
||||
const isBold = useMemo(() => {
|
||||
const text = value ?? "";
|
||||
const { start, end } = cursorPos;
|
||||
if (start !== end) {
|
||||
// Check if selection is wrapped in **
|
||||
return text.slice(Math.max(0, start - 2), start) === "**" && text.slice(end, end + 2) === "**";
|
||||
}
|
||||
// Check if cursor is inside **...**
|
||||
const before = text.slice(0, start);
|
||||
const after = text.slice(start);
|
||||
const lastOpen = before.lastIndexOf("**");
|
||||
if (lastOpen === -1) return false;
|
||||
const betweenOpen = before.slice(lastOpen + 2);
|
||||
if (betweenOpen.includes("**")) return false;
|
||||
return after.indexOf("**") !== -1;
|
||||
}, [value, cursorPos]);
|
||||
|
||||
const isItalic = useMemo(() => {
|
||||
const text = value ?? "";
|
||||
const { start, end } = cursorPos;
|
||||
if (start !== end) {
|
||||
const cb = text[start - 1];
|
||||
const ca = text[end];
|
||||
return cb === "*" && ca === "*" && text[start - 2] !== "*" && text[end + 1] !== "*";
|
||||
}
|
||||
const before = text.slice(0, start);
|
||||
const after = text.slice(start);
|
||||
// Find single * (not **) before cursor
|
||||
const lastStar = before.lastIndexOf("*");
|
||||
if (lastStar === -1) return false;
|
||||
if (lastStar > 0 && before[lastStar - 1] === "*") return false;
|
||||
const nextStar = after.indexOf("*");
|
||||
if (nextStar === -1) return false;
|
||||
if (after[nextStar + 1] === "*") return false;
|
||||
return true;
|
||||
}, [value, cursorPos]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
// Use e.code for layout-independent shortcuts (works with Russian layout)
|
||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyB") {
|
||||
e.preventDefault();
|
||||
wrapSelection("**", "**");
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyI") {
|
||||
e.preventDefault();
|
||||
wrapSelection("*", "*");
|
||||
}
|
||||
}, [wrapSelection]);
|
||||
|
||||
const toolbarBtn = (active: boolean) =>
|
||||
`rounded p-1.5 transition-colors ${
|
||||
active
|
||||
? "text-gold bg-gold/15"
|
||||
: "text-neutral-500 hover:text-neutral-900 hover:bg-neutral-200 dark:hover:text-white dark:hover:bg-white/10"
|
||||
}`;
|
||||
|
||||
// Preview mode: show rendered markup
|
||||
if (!editing && hasContent) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<div
|
||||
onClick={() => {
|
||||
setEditing(true);
|
||||
requestAnimationFrame(() => ref.current?.focus());
|
||||
}}
|
||||
className="group rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 cursor-text hover:border-gold/30 transition-colors relative dark:border-white/10 dark:bg-neutral-800"
|
||||
>
|
||||
<div className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
|
||||
{formatMarkup(value)}
|
||||
</div>
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="flex items-center gap-1 text-xs text-neutral-500">
|
||||
<Pencil size={10} />
|
||||
редактировать
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<div className="rounded-lg border border-neutral-200 bg-neutral-100 overflow-hidden hover:border-gold/30 focus-within:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-0.5 px-2 py-1 border-b border-neutral-200 dark:border-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => wrapSelection("**", "**")}
|
||||
className={toolbarBtn(isBold)}
|
||||
title="Жирный (Ctrl+B)"
|
||||
>
|
||||
<Bold size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => wrapSelection("*", "*")}
|
||||
className={toolbarBtn(isItalic)}
|
||||
title="Курсив (Ctrl+I)"
|
||||
>
|
||||
<Italic size={14} />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-white/10 mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertAtCursor("🤍 ")}
|
||||
className={toolbarBtn(false)}
|
||||
title="Пункт списка"
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => wrapSelection("## ", "")}
|
||||
className={toolbarBtn(false)}
|
||||
title="Подзаголовок"
|
||||
>
|
||||
<Heading2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={updateCursorPos}
|
||||
onClick={updateCursorPos}
|
||||
onSelect={updateCursorPos}
|
||||
onBlur={() => setEditing(false)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full bg-transparent px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none resize-none dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function SelectField({
|
||||
@@ -167,9 +418,11 @@ export function SelectField({
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
hint,
|
||||
}: SelectFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -181,6 +434,33 @@ export function SelectField({
|
||||
})
|
||||
: options;
|
||||
|
||||
const showSearch = options.length > 3;
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (!open) { setOpen(true); setHighlightIndex(0); return; }
|
||||
setHighlightIndex((prev) => (prev + 1) % filtered.length);
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (!open) { setOpen(true); setHighlightIndex(filtered.length - 1); return; }
|
||||
setHighlightIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
|
||||
}
|
||||
if (e.key === "Enter" && open && highlightIndex >= 0 && highlightIndex < filtered.length) {
|
||||
e.preventDefault();
|
||||
onChange(filtered[highlightIndex].value);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
setHighlightIndex(-1);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handle(e: MouseEvent) {
|
||||
@@ -195,51 +475,74 @@ export function SelectField({
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{label && <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(!open);
|
||||
setSearch("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
|
||||
>
|
||||
{selectedLabel || placeholder || "Выберите..."}
|
||||
</button>
|
||||
{label && (
|
||||
<label className="flex items-center gap-1.5 text-sm text-neutral-400 mb-1.5">
|
||||
{label}
|
||||
{hint && (
|
||||
<span className="group relative">
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-neutral-300 text-[10px] text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors cursor-help dark:border-white/15 dark:hover:text-white dark:hover:border-white/30">?</span>
|
||||
<span className="absolute left-6 top-1/2 -translate-y-1/2 z-50 w-52 rounded-lg border border-neutral-200 bg-white px-3 py-2 text-[11px] leading-relaxed text-neutral-700 shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
{hint}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
{showSearch ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={open ? search : selectedLabel}
|
||||
onChange={(e) => { setSearch(e.target.value); if (!open) setOpen(true); setHighlightIndex(0); }}
|
||||
onFocus={() => { setOpen(true); setSearch(""); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
placeholder={placeholder || "Выберите..."}
|
||||
className={`w-full rounded-lg border bg-neutral-100 text-neutral-900 outline-none transition-colors dark:bg-neutral-800 dark:text-white ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
} ${open ? "border-gold" : "border-neutral-200 dark:border-white/10"} placeholder-neutral-400 dark:placeholder-neutral-500`}
|
||||
/>
|
||||
) : (
|
||||
<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 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
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск..."
|
||||
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div role="listbox" className="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/10 dark:bg-neutral-800">
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
|
||||
)}
|
||||
{filtered.map((opt) => (
|
||||
{filtered.map((opt, idx) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
key={opt.value || `opt-${idx}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={opt.value === value}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHighlightIndex(idx)}
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
setHighlightIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-white/5 ${
|
||||
opt.value === value ? "text-gold bg-gold/5" : "text-white"
|
||||
}`}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors ${
|
||||
idx === highlightIndex ? "bg-neutral-100 dark:bg-white/10" : "hover:bg-neutral-50 dark:hover:bg-white/5"
|
||||
} ${opt.value === value ? "text-gold bg-gold/5" : "text-neutral-900 dark:text-white"}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
@@ -282,7 +585,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
|
||||
}
|
||||
|
||||
function handleEndChange(newEnd: string) {
|
||||
if (start && newEnd && newEnd <= start) return;
|
||||
// Always allow the change — validation handles the error display
|
||||
update(start, newEnd);
|
||||
}
|
||||
|
||||
@@ -295,7 +598,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
|
||||
value={start}
|
||||
onChange={(e) => handleStartChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2.5 text-neutral-900 outline-none focus:border-gold transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]"
|
||||
/>
|
||||
<span className="text-neutral-500">–</span>
|
||||
<input
|
||||
@@ -303,7 +606,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
|
||||
value={end}
|
||||
onChange={(e) => handleEndChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2.5 text-neutral-900 outline-none focus:border-gold transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,7 +677,7 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
|
||||
type="text"
|
||||
value={item}
|
||||
onChange={(e) => update(i, e.target.value)}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -391,6 +694,7 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||
onBlur={add}
|
||||
placeholder={placeholder || "Добавить..."}
|
||||
className={dashedInput}
|
||||
/>
|
||||
@@ -414,11 +718,13 @@ interface VictoryListFieldProps {
|
||||
onChange: (items: RichListItem[]) => void;
|
||||
placeholder?: string;
|
||||
onLinkValidate?: (key: string, error: string | null) => void;
|
||||
onUploadComplete?: () => void;
|
||||
}
|
||||
|
||||
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate }: VictoryListFieldProps) {
|
||||
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate, onUploadComplete }: VictoryListFieldProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
|
||||
function add() {
|
||||
const val = draft.trim();
|
||||
@@ -447,6 +753,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploadingIndex(index);
|
||||
setUploadError("");
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("folder", "team");
|
||||
@@ -455,8 +762,13 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
const result = await res.json();
|
||||
if (result.path) {
|
||||
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
||||
onUploadComplete?.();
|
||||
} else {
|
||||
setUploadError(result.error || "Ошибка загрузки");
|
||||
}
|
||||
} catch { /* upload failed */ } finally {
|
||||
} catch {
|
||||
setUploadError("Не удалось загрузить файл");
|
||||
} finally {
|
||||
setUploadingIndex(null);
|
||||
}
|
||||
}
|
||||
@@ -466,13 +778,13 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<div className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5">
|
||||
<div key={i} className="rounded-lg border border-neutral-200 bg-neutral-100/80 p-2.5 space-y-1.5 transition-colors hover:border-gold/30 hover:bg-neutral-200/80 focus-within:border-gold/50 focus-within:bg-neutral-200 dark:border-white/10 dark:bg-neutral-800/50 dark:hover:bg-neutral-800/80 dark:focus-within:bg-neutral-800">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={item.text}
|
||||
onChange={(e) => updateText(i, e.target.value)}
|
||||
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||
className="flex-1 rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -484,7 +796,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.image ? (
|
||||
<div className="flex items-center gap-1 rounded bg-neutral-700/50 px-1.5 py-0.5 text-[11px] text-neutral-300">
|
||||
<div className="flex items-center gap-1 rounded bg-neutral-200 px-1.5 py-0.5 text-[11px] text-neutral-700 dark:bg-neutral-700/50 dark:text-neutral-300">
|
||||
<ImageIcon size={10} className="text-gold" />
|
||||
<span className="max-w-[80px] truncate">{item.image.split("/").pop()}</span>
|
||||
<button type="button" onClick={() => removeImage(i)} className="text-neutral-500 hover:text-red-400">
|
||||
@@ -513,6 +825,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||
onBlur={add}
|
||||
placeholder={placeholder || "Добавить..."}
|
||||
className={dashedInput}
|
||||
/>
|
||||
@@ -526,150 +839,8 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Date Range Picker ---
|
||||
// Parses Russian date formats: "22.02.2025", "22-23.02.2025", "22.02-01.03.2025"
|
||||
function parseDateRange(value: string): { start: string; end: string } {
|
||||
if (!value) return { start: "", end: "" };
|
||||
|
||||
// "22-23.02.2025" → same month range
|
||||
const sameMonth = value.match(/^(\d{1,2})-(\d{1,2})\.(\d{2})\.(\d{4})$/);
|
||||
if (sameMonth) {
|
||||
const [, d1, d2, m, y] = sameMonth;
|
||||
return {
|
||||
start: `${y}-${m}-${d1.padStart(2, "0")}`,
|
||||
end: `${y}-${m}-${d2.padStart(2, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// "22.02-01.03.2025" → cross-month range
|
||||
const crossMonth = value.match(/^(\d{1,2})\.(\d{2})-(\d{1,2})\.(\d{2})\.(\d{4})$/);
|
||||
if (crossMonth) {
|
||||
const [, d1, m1, d2, m2, y] = crossMonth;
|
||||
return {
|
||||
start: `${y}-${m1}-${d1.padStart(2, "0")}`,
|
||||
end: `${y}-${m2}-${d2.padStart(2, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// "22.02.2025" → single date
|
||||
const single = value.match(/^(\d{1,2})\.(\d{2})\.(\d{4})$/);
|
||||
if (single) {
|
||||
const [, d, m, y] = single;
|
||||
const iso = `${y}-${m}-${d.padStart(2, "0")}`;
|
||||
return { start: iso, end: "" };
|
||||
}
|
||||
|
||||
return { start: "", end: "" };
|
||||
}
|
||||
|
||||
function formatDateRange(start: string, end: string): string {
|
||||
if (!start) return "";
|
||||
const [sy, sm, sd] = start.split("-");
|
||||
if (!end) return `${sd}.${sm}.${sy}`;
|
||||
const [ey, em, ed] = end.split("-");
|
||||
if (sm === em && sy === ey) return `${sd}-${ed}.${sm}.${sy}`;
|
||||
return `${sd}.${sm}-${ed}.${em}.${ey}`;
|
||||
}
|
||||
|
||||
interface DateRangeFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function DateRangeField({ value, onChange }: DateRangeFieldProps) {
|
||||
const { start, end } = parseDateRange(value);
|
||||
|
||||
function handleChange(s: string, e: string) {
|
||||
onChange(formatDateRange(s, e));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={11} className="text-neutral-500 shrink-0" />
|
||||
<input
|
||||
type="date"
|
||||
value={start}
|
||||
onChange={(e) => handleChange(e.target.value, end)}
|
||||
className="w-[130px] rounded-md border border-white/10 bg-neutral-800 px-1.5 py-1.5 text-xs text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
||||
/>
|
||||
<span className="text-neutral-500 text-xs">—</span>
|
||||
<input
|
||||
type="date"
|
||||
value={end}
|
||||
min={start}
|
||||
onChange={(e) => handleChange(start, e.target.value)}
|
||||
className="w-[130px] rounded-md border border-white/10 bg-neutral-800 px-1.5 py-1.5 text-xs text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- City Autocomplete Field ---
|
||||
interface CityFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
onSearch?: (query: string) => void;
|
||||
suggestions?: string[];
|
||||
onSelectSuggestion?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function CityField({ value, onChange, error, onSearch, suggestions, onSelectSuggestion }: CityFieldProps) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) return;
|
||||
function handle(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setFocused(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handle);
|
||||
return () => document.removeEventListener("mousedown", handle);
|
||||
}, [focused]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex-1">
|
||||
<div className="relative">
|
||||
<MapPin size={11} className="absolute left-2 top-1/2 -translate-y-1/2 text-neutral-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
onSearch?.(e.target.value);
|
||||
}}
|
||||
onFocus={() => setFocused(true)}
|
||||
placeholder="Город, страна"
|
||||
className={`w-full rounded-md border bg-neutral-800 pl-6 pr-3 py-1.5 text-sm text-white placeholder-neutral-600 outline-none transition-colors ${
|
||||
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||
}`}
|
||||
/>
|
||||
{error && <AlertCircle size={12} className="absolute right-2 top-1/2 -translate-y-1/2 text-red-400" />}
|
||||
</div>
|
||||
{error && <p className="mt-0.5 text-[10px] text-red-400">{error}</p>}
|
||||
{focused && suggestions && suggestions.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onSelectSuggestion?.(s);
|
||||
setFocused(false);
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{uploadError && (
|
||||
<p role="alert" className="mt-1.5 text-xs text-red-400">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -715,8 +886,8 @@ export function ValidatedLinkField({ value, onChange, onValidate, validationKey,
|
||||
validate(e.target.value);
|
||||
}}
|
||||
placeholder={placeholder || "Ссылка..."}
|
||||
className={`w-full rounded-md border bg-neutral-800 px-2 py-1 text-xs text-white placeholder-neutral-600 outline-none transition-colors ${
|
||||
error ? "border-red-500/50" : "border-white/5 focus:border-gold/50"
|
||||
className={`w-full rounded-md border bg-neutral-100 px-2 py-1 text-xs text-neutral-900 placeholder-neutral-400 outline-none transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600 ${
|
||||
error ? "border-red-500/50" : "border-neutral-200 focus:border-gold/50 dark:border-white/5"
|
||||
}`}
|
||||
/>
|
||||
{error && (
|
||||
@@ -729,110 +900,6 @@ export function ValidatedLinkField({ value, onChange, onValidate, validationKey,
|
||||
);
|
||||
}
|
||||
|
||||
interface VictoryItemListFieldProps {
|
||||
label: string;
|
||||
items: VictoryItem[];
|
||||
onChange: (items: VictoryItem[]) => void;
|
||||
cityErrors?: Record<number, string>;
|
||||
citySuggestions?: { index: number; items: string[] } | null;
|
||||
onCitySearch?: (index: number, query: string) => void;
|
||||
onCitySelect?: (index: number, value: string) => void;
|
||||
onLinkValidate?: (key: string, error: string | null) => void;
|
||||
}
|
||||
|
||||
export function VictoryItemListField({ label, items, onChange, cityErrors, citySuggestions, onCitySearch, onCitySelect, onLinkValidate }: VictoryItemListFieldProps) {
|
||||
function add() {
|
||||
onChange([...items, { type: "place", place: "", category: "", competition: "" }]);
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function update(index: number, field: keyof VictoryItem, value: string) {
|
||||
onChange(items.map((item, i) => (i === index ? { ...item, [field]: value || undefined } : item)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<div className="space-y-3">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<select
|
||||
value={item.type || "place"}
|
||||
onChange={(e) => update(i, "type", e.target.value)}
|
||||
className="w-32 shrink-0 rounded-md border border-white/10 bg-neutral-800 px-2 py-1.5 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||
>
|
||||
<option value="place">Место</option>
|
||||
<option value="nomination">Номинация</option>
|
||||
<option value="judge">Судейство</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={item.place || ""}
|
||||
onChange={(e) => update(i, "place", e.target.value)}
|
||||
placeholder="1 место, финалист..."
|
||||
className={`w-28 shrink-0 ${smallInput}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.category || ""}
|
||||
onChange={(e) => update(i, "category", e.target.value)}
|
||||
placeholder="Категория"
|
||||
className={`flex-1 ${smallInput}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.competition || ""}
|
||||
onChange={(e) => update(i, "competition", e.target.value)}
|
||||
placeholder="Чемпионат"
|
||||
className={`flex-1 ${smallInput}`}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<CityField
|
||||
value={item.location || ""}
|
||||
onChange={(v) => update(i, "location", v)}
|
||||
error={cityErrors?.[i]}
|
||||
onSearch={(q) => onCitySearch?.(i, q)}
|
||||
suggestions={citySuggestions?.index === i ? citySuggestions.items : undefined}
|
||||
onSelectSuggestion={(v) => onCitySelect?.(i, v)}
|
||||
/>
|
||||
<DateRangeField
|
||||
value={item.date || ""}
|
||||
onChange={(v) => update(i, "date", v)}
|
||||
/>
|
||||
</div>
|
||||
<ValidatedLinkField
|
||||
value={item.link || ""}
|
||||
onChange={(v) => update(i, "link", v)}
|
||||
validationKey={`victory-${i}`}
|
||||
onValidate={onLinkValidate}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Добавить достижение
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Autocomplete Multi-Select ---
|
||||
export function AutocompleteMulti({
|
||||
label,
|
||||
@@ -898,8 +965,8 @@ export function AutocompleteMulti({
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<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 hover:border-gold/30"
|
||||
className={`flex flex-wrap items-center gap-1.5 rounded-lg border bg-neutral-100 px-3 py-2 min-h-[42px] cursor-text transition-colors dark:bg-neutral-800 ${
|
||||
open ? "border-gold" : "border-neutral-200 hover:border-gold/30 dark:border-white/10"
|
||||
}`}
|
||||
>
|
||||
{selected.map((item) => (
|
||||
@@ -918,14 +985,14 @@ export function AutocompleteMulti({
|
||||
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"
|
||||
className="flex-1 min-w-[80px] bg-transparent text-sm text-neutral-900 placeholder-neutral-400 outline-none dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden max-h-48 overflow-y-auto">
|
||||
<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-white hover:bg-white/5 transition-colors">
|
||||
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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import { adminFetch } from "@/lib/csrf";
|
||||
interface SectionEditorProps<T> {
|
||||
sectionKey: string;
|
||||
title: string;
|
||||
defaultData?: Partial<T>;
|
||||
/** Return true if data is valid and can be saved. Blocks auto-save when false. */
|
||||
validate?: (data: T) => boolean;
|
||||
children: (data: T, update: (data: T) => void) => React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -15,14 +18,19 @@ const DEBOUNCE_MS = 800;
|
||||
export function SectionEditor<T>({
|
||||
sectionKey,
|
||||
title,
|
||||
defaultData,
|
||||
validate,
|
||||
children,
|
||||
}: SectionEditorProps<T>) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error" | "invalid">("idle");
|
||||
const [error, setError] = useState("");
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initialLoadRef = useRef(true);
|
||||
const pendingSaveRef = useRef(false);
|
||||
const defaultDataRef = useRef(defaultData);
|
||||
defaultDataRef.current = defaultData;
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch(`/api/admin/sections/${sectionKey}`)
|
||||
@@ -30,7 +38,7 @@ export function SectionEditor<T>({
|
||||
if (!r.ok) throw new Error("Failed to load");
|
||||
return r.json();
|
||||
})
|
||||
.then(setData)
|
||||
.then((loaded) => setData(defaultDataRef.current ? { ...defaultDataRef.current, ...loaded } as T : loaded))
|
||||
.catch(() => setError("Не удалось загрузить данные"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [sectionKey]);
|
||||
@@ -63,8 +71,13 @@ export function SectionEditor<T>({
|
||||
return;
|
||||
}
|
||||
|
||||
pendingSaveRef.current = true;
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (validate && !validate(data)) {
|
||||
setStatus("invalid");
|
||||
return;
|
||||
}
|
||||
save(data);
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
@@ -73,6 +86,41 @@ export function SectionEditor<T>({
|
||||
};
|
||||
}, [data, save]);
|
||||
|
||||
// Clear pending flag after save completes
|
||||
useEffect(() => {
|
||||
if (status === "saved") pendingSaveRef.current = false;
|
||||
}, [status]);
|
||||
|
||||
// Warn before leaving with unsaved changes
|
||||
useEffect(() => {
|
||||
function onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (pendingSaveRef.current) e.preventDefault();
|
||||
}
|
||||
function onLinkClick(e: MouseEvent) {
|
||||
if (!pendingSaveRef.current) return;
|
||||
const link = (e.target as HTMLElement).closest("a");
|
||||
if (!link || link.target === "_blank") return;
|
||||
const href = link.getAttribute("href");
|
||||
if (!href || href.startsWith("#")) return;
|
||||
// Force save immediately before navigating
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (data && (!validate || validate(data))) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
save(data).then(() => {
|
||||
pendingSaveRef.current = false;
|
||||
window.location.href = href;
|
||||
});
|
||||
}
|
||||
}
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
document.addEventListener("click", onLinkClick, true);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
document.removeEventListener("click", onLinkClick, true);
|
||||
};
|
||||
}, [data, save, validate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
@@ -91,14 +139,15 @@ export function SectionEditor<T>({
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
|
||||
{/* Fixed toast popup */}
|
||||
{(status === "saved" || status === "error") && (
|
||||
<div className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||
{(status === "saved" || status === "error" || status === "invalid") && (
|
||||
<div role="status" aria-live="polite" className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||
status === "saved"
|
||||
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
|
||||
: "bg-red-950/90 border-red-500/30 text-red-200"
|
||||
}`}>
|
||||
{status === "saved" && <><Check size={14} /> Сохранено</>}
|
||||
{status === "error" && <><AlertCircle size={14} /> {error}</>}
|
||||
{status === "invalid" && <><AlertCircle size={14} /> Не сохранено — исправьте ошибки</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
<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">
|
||||
<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}
|
||||
@@ -54,6 +54,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
{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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, TextareaField } from "../_components/FormField";
|
||||
import { InputField } from "../_components/FormField";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
|
||||
interface AboutData {
|
||||
@@ -11,7 +11,7 @@ interface AboutData {
|
||||
|
||||
export default function AboutEditorPage() {
|
||||
return (
|
||||
<SectionEditor<AboutData> sectionKey="about" title="О студии">
|
||||
<SectionEditor<AboutData> sectionKey="about" title="О студии" defaultData={{ paragraphs: [] }}>
|
||||
{(data, update) => (
|
||||
<>
|
||||
<InputField
|
||||
@@ -23,12 +23,14 @@ export default function AboutEditorPage() {
|
||||
label="Параграфы"
|
||||
items={data.paragraphs}
|
||||
onChange={(paragraphs) => update({ ...data, paragraphs })}
|
||||
inline
|
||||
renderItem={(text, _i, updateItem) => (
|
||||
<TextareaField
|
||||
label={`Параграф`}
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={updateItem}
|
||||
rows={3}
|
||||
onChange={(e) => updateItem(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
|
||||
placeholder="Текст параграфа..."
|
||||
/>
|
||||
)}
|
||||
createItem={() => ""}
|
||||
|
||||
@@ -1,16 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { X, ChevronDown } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { formatBelarusPhone, SHORT_DAYS } from "@/lib/formatting";
|
||||
|
||||
type Tab = "classes" | "events";
|
||||
type EventType = "master-class" | "open-day";
|
||||
|
||||
interface McOption { title: string; date: string }
|
||||
interface OdClass { id: number; style: string; time: string; hall: string }
|
||||
interface OdClass { id: number; style: string; start_time: string; hall: string; trainer: string }
|
||||
interface OdEvent { id: number; date: string; title?: string }
|
||||
interface ScheduleClass { type: string; trainer: string; time: string; day: string; hall: string; groupId?: string }
|
||||
|
||||
function shortName(fullName: string) {
|
||||
const parts = fullName.trim().split(/\s+/);
|
||||
// Names stored as "Имя Фамилия" → show "Фамилия И."
|
||||
return parts.length > 1 ? `${parts[1]} ${parts[0][0]}.` : parts[0];
|
||||
}
|
||||
|
||||
|
||||
// --- Searchable dropdown ---
|
||||
|
||||
interface SearchSelectOption { value: string; label: string }
|
||||
|
||||
function SearchSelect({ options, value, onChange, placeholder }: {
|
||||
options: SearchSelectOption[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
const filtered = search
|
||||
? (() => {
|
||||
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return options.filter((o) => {
|
||||
const label = o.label.toLowerCase();
|
||||
return tokens.every((t) => label.includes(t));
|
||||
});
|
||||
})()
|
||||
: options;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handle(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handle);
|
||||
return () => document.removeEventListener("mousedown", handle);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div
|
||||
onClick={() => { setOpen(true); setTimeout(() => inputRef.current?.focus(), 0); }}
|
||||
className={`flex items-center gap-2 w-full rounded-lg border px-3 py-2 text-sm cursor-text transition-colors ${
|
||||
open ? "border-gold/40 bg-neutral-200/60 dark:bg-white/[0.06]" : "border-neutral-200 bg-neutral-100 dark:border-white/[0.08] dark:bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
{open ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={selected ? selected.label : placeholder}
|
||||
className="flex-1 bg-transparent text-neutral-900 placeholder-neutral-400 outline-none text-sm dark:text-white dark:placeholder-neutral-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") { setOpen(false); setSearch(""); }
|
||||
if (e.key === "Backspace" && !search && value) { onChange(""); }
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className={`flex-1 truncate ${selected ? "text-neutral-900 dark:text-white" : "text-neutral-500"}`}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
)}
|
||||
{value && !open ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onChange(""); }}
|
||||
className="text-neutral-500 hover:text-white transition-colors shrink-0"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<ChevronDown size={14} className={`text-neutral-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/[0.08] dark:bg-[#141414]">
|
||||
<div className="max-h-48 overflow-y-scroll admin-scrollbar">
|
||||
{filtered.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-neutral-500">Ничего не найдено</p>
|
||||
)}
|
||||
{filtered.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => { onChange(o.value); setOpen(false); setSearch(""); }}
|
||||
className={`w-full px-3 py-2 text-left text-sm transition-colors ${
|
||||
o.value === value ? "bg-gold/10 text-gold" : "text-neutral-900 hover:bg-neutral-50 dark:text-white dark:hover:bg-white/[0.05]"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Modal ---
|
||||
|
||||
export function AddBookingModal({
|
||||
open,
|
||||
@@ -32,13 +147,28 @@ export function AddBookingModal({
|
||||
const [odClasses, setOdClasses] = useState<OdClass[]>([]);
|
||||
const [odEventId, setOdEventId] = useState<number | null>(null);
|
||||
const [odClassId, setOdClassId] = useState("");
|
||||
const [scheduleClasses, setScheduleClasses] = useState<ScheduleClass[]>([]);
|
||||
const [classGroup, setClassGroup] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId("");
|
||||
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassGroup("");
|
||||
|
||||
// Fetch upcoming MCs (filter out expired)
|
||||
// Fetch schedule classes
|
||||
adminFetch("/api/admin/sections/schedule").then((r) => r.json()).then((data: { locations?: { name: string; days: { day: string; classes: { type: string; trainer: string; time: string; groupId?: string }[] }[] }[] }) => {
|
||||
const classes: ScheduleClass[] = [];
|
||||
for (const loc of data.locations || []) {
|
||||
for (const day of loc.days) {
|
||||
for (const cls of day.classes) {
|
||||
classes.push({ type: cls.type, trainer: cls.trainer, time: cls.time, day: day.day, hall: loc.name, groupId: cls.groupId });
|
||||
}
|
||||
}
|
||||
}
|
||||
setScheduleClasses(classes);
|
||||
}).catch(() => {});
|
||||
|
||||
// Fetch upcoming MCs
|
||||
adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()).then((data: { items?: { title: string; slots: { date: string }[] }[] }) => {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const upcoming = (data.items || [])
|
||||
@@ -74,34 +204,61 @@ export function AddBookingModal({
|
||||
}, [open, onClose]);
|
||||
|
||||
function handlePhoneChange(raw: string) {
|
||||
let digits = raw.replace(/\D/g, "");
|
||||
if (!digits.startsWith("375")) digits = "375" + digits.replace(/^375?/, "");
|
||||
digits = digits.slice(0, 12);
|
||||
let formatted = "+375";
|
||||
const rest = digits.slice(3);
|
||||
if (rest.length > 0) formatted += " (" + rest.slice(0, 2);
|
||||
if (rest.length >= 2) formatted += ") ";
|
||||
if (rest.length > 2) formatted += rest.slice(2, 5);
|
||||
if (rest.length > 5) formatted += "-" + rest.slice(5, 7);
|
||||
if (rest.length > 7) formatted += "-" + rest.slice(7, 9);
|
||||
setPhone(formatted);
|
||||
setPhone(formatBelarusPhone(raw));
|
||||
}
|
||||
|
||||
const hasUpcomingMc = mcOptions.length > 0;
|
||||
const hasOpenDay = odEventId !== null && odClasses.length > 0;
|
||||
const hasEvents = hasUpcomingMc || hasOpenDay;
|
||||
|
||||
// Flat group options: one searchable dropdown
|
||||
const classGroupOptions = useMemo((): SearchSelectOption[] => {
|
||||
const byKey = new Map<string, { type: string; trainer: string; hall: string; slots: { day: string; time: string }[]; id: string }>();
|
||||
for (const c of scheduleClasses) {
|
||||
const id = c.groupId || `${c.type}|${c.trainer}|${c.time}|${c.hall}`;
|
||||
const existing = byKey.get(id);
|
||||
if (existing) {
|
||||
if (!existing.slots.some((s) => s.day === c.day)) existing.slots.push({ day: c.day, time: c.time });
|
||||
} else {
|
||||
byKey.set(id, { type: c.type, trainer: c.trainer, hall: c.hall, slots: [{ day: c.day, time: c.time }], id });
|
||||
}
|
||||
}
|
||||
return [...byKey.values()].map((g) => {
|
||||
const sameTime = g.slots.every((s) => s.time === g.slots[0].time);
|
||||
const days = sameTime
|
||||
? `${g.slots.map((s) => SHORT_DAYS[s.day] || s.day.slice(0, 2)).join("/")} ${g.slots[0].time}`
|
||||
: g.slots.map((s) => `${SHORT_DAYS[s.day] || s.day.slice(0, 2)} ${s.time}`).join(", ");
|
||||
return {
|
||||
value: g.id,
|
||||
label: `${shortName(g.trainer)} · ${g.type} · ${days} · ${g.hall}`,
|
||||
};
|
||||
}).sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [scheduleClasses]);
|
||||
|
||||
const mcSelectOptions: SearchSelectOption[] = mcOptions.map((mc) => ({
|
||||
value: mc.title,
|
||||
label: mc.title,
|
||||
}));
|
||||
|
||||
const odSelectOptions: SearchSelectOption[] = odClasses.map((c) => ({
|
||||
value: String(c.id),
|
||||
label: `${shortName(c.trainer)} — ${c.start_time} · ${c.hall}`,
|
||||
}));
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!name.trim() || !phone.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (tab === "classes") {
|
||||
const groupInfo = classGroup
|
||||
? classGroupOptions.find((o) => o.value === classGroup)?.label
|
||||
: undefined;
|
||||
await adminFetch("/api/admin/group-bookings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
phone: phone.trim(),
|
||||
...(groupInfo && { groupInfo }),
|
||||
...(instagram.trim() && { instagram: instagram.trim() }),
|
||||
...(telegram.trim() && { telegram: telegram.trim() }),
|
||||
}),
|
||||
@@ -122,6 +279,8 @@ export function AddBookingModal({
|
||||
}
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch {
|
||||
alert("Не удалось создать запись. Попробуйте ещё раз.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -129,19 +288,7 @@ export function AddBookingModal({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const inputClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 placeholder-neutral-500";
|
||||
const tabBtn = (key: Tab, label: string, disabled?: boolean) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => !disabled && setTab(key)}
|
||||
disabled={disabled}
|
||||
className={`flex-1 rounded-lg py-2 text-xs font-medium transition-all ${
|
||||
tab === key ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
const inputClass = "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 placeholder-neutral-400 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500";
|
||||
|
||||
const canSubmit = name.trim() && phone.trim() && !saving
|
||||
&& (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc)
|
||||
@@ -150,69 +297,75 @@ export function AddBookingModal({
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={onClose} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
|
||||
<div className="relative w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={onClose} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white">
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
<h3 className="text-base font-bold text-white">Добавить запись</h3>
|
||||
<p className="mt-1 text-xs text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
|
||||
<h3 className="text-base font-bold text-neutral-900 dark:text-white">Добавить запись</h3>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Tab: Classes vs Events */}
|
||||
<div className="flex gap-2">
|
||||
{tabBtn("classes", "Занятие")}
|
||||
{tabBtn("events", "Мероприятие", !hasEvents)}
|
||||
{/* Type selector — single row */}
|
||||
<div className="flex rounded-lg border border-neutral-200 bg-neutral-100 p-0.5 dark:border-white/[0.08] dark:bg-white/[0.03]">
|
||||
<button
|
||||
onClick={() => setTab("classes")}
|
||||
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
||||
tab === "classes" ? "bg-gold/20 text-gold shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Занятие
|
||||
</button>
|
||||
{hasUpcomingMc && (
|
||||
<button
|
||||
onClick={() => { setTab("events"); setEventType("master-class"); }}
|
||||
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
||||
tab === "events" && eventType === "master-class" ? "bg-purple-500/15 text-purple-400 shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Мастер-класс
|
||||
</button>
|
||||
)}
|
||||
{hasOpenDay && (
|
||||
<button
|
||||
onClick={() => { setTab("events"); setEventType("open-day"); }}
|
||||
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
||||
tab === "events" && eventType === "open-day" ? "bg-blue-500/15 text-blue-400 shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Open Day
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Events sub-selector */}
|
||||
{tab === "events" && (
|
||||
<div className="flex gap-2">
|
||||
{hasUpcomingMc && (
|
||||
<button
|
||||
onClick={() => setEventType("master-class")}
|
||||
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
|
||||
eventType === "master-class" ? "bg-purple-500/15 text-purple-400 border border-purple-500/30" : "bg-neutral-800/50 text-neutral-500 border border-white/5 hover:text-neutral-300"
|
||||
}`}
|
||||
>
|
||||
Мастер-класс
|
||||
</button>
|
||||
)}
|
||||
{hasOpenDay && (
|
||||
<button
|
||||
onClick={() => setEventType("open-day")}
|
||||
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
|
||||
eventType === "open-day" ? "bg-blue-500/15 text-blue-400 border border-blue-500/30" : "bg-neutral-800/50 text-neutral-500 border border-white/5 hover:text-neutral-300"
|
||||
}`}
|
||||
>
|
||||
Open Day
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Class selector (optional for Занятие) */}
|
||||
{tab === "classes" && classGroupOptions.length > 0 && (
|
||||
<SearchSelect
|
||||
options={classGroupOptions}
|
||||
value={classGroup}
|
||||
onChange={setClassGroup}
|
||||
placeholder="Группа (необязательно)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MC selector */}
|
||||
{tab === "events" && eventType === "master-class" && mcOptions.length > 0 && (
|
||||
<select value={mcTitle} onChange={(e) => setMcTitle(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
|
||||
<option value="" className="bg-neutral-900">Выберите мастер-класс</option>
|
||||
{mcOptions.map((mc) => (
|
||||
<option key={mc.title} value={mc.title} className="bg-neutral-900">
|
||||
{mc.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{tab === "events" && eventType === "master-class" && mcSelectOptions.length > 0 && (
|
||||
<SearchSelect
|
||||
options={mcSelectOptions}
|
||||
value={mcTitle}
|
||||
onChange={setMcTitle}
|
||||
placeholder="Выберите мастер-класс"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Open Day class selector */}
|
||||
{tab === "events" && eventType === "open-day" && odClasses.length > 0 && (
|
||||
<select value={odClassId} onChange={(e) => setOdClassId(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
|
||||
<option value="" className="bg-neutral-900">Выберите занятие</option>
|
||||
{odClasses.map((c) => (
|
||||
<option key={c.id} value={c.id} className="bg-neutral-900">
|
||||
{c.time} · {c.style} · {c.hall}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{tab === "events" && eventType === "open-day" && odSelectOptions.length > 0 && (
|
||||
<SearchSelect
|
||||
options={odSelectOptions}
|
||||
value={odClassId}
|
||||
onChange={setOdClassId}
|
||||
placeholder="Выберите занятие"
|
||||
/>
|
||||
)}
|
||||
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Имя" className={inputClass} />
|
||||
|
||||
@@ -47,17 +47,17 @@ export function DeleteBtn({ onClick, name }: { onClick: () => void; name?: strin
|
||||
{confirming && createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirming(false)}>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
<div className="relative w-full max-w-xs rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => setConfirming(false)} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
|
||||
<div className="relative w-full max-w-xs rounded-2xl border border-neutral-200 bg-white p-5 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => setConfirming(false)} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white">
|
||||
<X size={16} />
|
||||
</button>
|
||||
<h3 className="text-sm font-bold text-white">Удалить запись?</h3>
|
||||
{name && <p className="mt-1 text-xs text-neutral-400">{name}</p>}
|
||||
<p className="mt-2 text-xs text-neutral-500">Это действие нельзя отменить.</p>
|
||||
<h3 className="text-sm font-bold text-neutral-900 dark:text-white">Удалить запись?</h3>
|
||||
{name && <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{name}</p>}
|
||||
<p className="mt-2 text-xs text-neutral-400 dark:text-neutral-500">Это действие нельзя отменить.</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 py-2 text-xs font-medium text-neutral-300 hover:bg-neutral-700 transition-colors"
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 py-2 text-xs font-medium text-neutral-700 hover:bg-neutral-200 transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
@@ -80,17 +80,17 @@ export function ContactLinks({ phone, instagram, telegram }: { phone?: string; i
|
||||
return (
|
||||
<>
|
||||
{phone && (
|
||||
<a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||
<a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-500 dark:text-emerald-400 dark:hover:text-emerald-300 text-xs">
|
||||
<Phone size={10} />{phone}
|
||||
</a>
|
||||
)}
|
||||
{instagram && (
|
||||
<a href={`https://ig.me/m/${instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
|
||||
<a href={`https://ig.me/m/${instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-600 hover:text-pink-500 dark:text-pink-400 dark:hover:text-pink-300 text-xs">
|
||||
<Instagram size={10} />{instagram}
|
||||
</a>
|
||||
)}
|
||||
{telegram && (
|
||||
<a href={`https://t.me/${telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
|
||||
<a href={`https://t.me/${telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 text-xs">
|
||||
<Send size={10} />{telegram}
|
||||
</a>
|
||||
)}
|
||||
@@ -109,7 +109,7 @@ export function FilterTabs({ filter, counts, total, onFilter }: {
|
||||
<button
|
||||
onClick={() => onFilter("all")}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-100 text-neutral-500 border border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Все <span className="text-neutral-500 ml-1">{total}</span>
|
||||
@@ -119,7 +119,7 @@ export function FilterTabs({ filter, counts, total, onFilter }: {
|
||||
key={s.key}
|
||||
onClick={() => onFilter(s.key)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-100 text-neutral-500 border border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
@@ -147,14 +147,14 @@ export function StatusActions({ status, onStatus }: { status: BookingStatus; onS
|
||||
);
|
||||
return (
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")}
|
||||
{status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")}
|
||||
{status === "contacted" && (
|
||||
<>
|
||||
{actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")}
|
||||
{actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20")}
|
||||
{actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")}
|
||||
{actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/30 hover:bg-red-500/20")}
|
||||
</>
|
||||
)}
|
||||
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300")}
|
||||
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-100 text-neutral-600 border border-neutral-300 hover:border-neutral-400 hover:text-neutral-800 dark:bg-neutral-800/50 dark:text-neutral-500 dark:border-transparent dark:hover:border-white/10 dark:hover:text-neutral-300")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -163,10 +163,10 @@ export function BookingCard({ status, highlight, children }: { status: BookingSt
|
||||
return (
|
||||
<div
|
||||
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 === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02] hover:border-emerald-500/30 hover:bg-emerald-500/[0.05]"
|
||||
: status === "new" ? "border-gold/20 bg-gold/[0.03] hover:border-gold/40 hover:bg-gold/[0.06]"
|
||||
: "border-white/10 bg-neutral-800/30 hover:border-white/20 hover:bg-neutral-800/50"
|
||||
status === "declined" ? "border-red-500/20 bg-red-500/[0.04] opacity-50 hover:opacity-70 hover:border-red-500/30 dark:border-red-500/15 dark:bg-red-500/[0.02]"
|
||||
: status === "confirmed" ? "border-emerald-500/20 bg-emerald-500/[0.04] hover:border-emerald-500/30 hover:bg-emerald-500/[0.08] dark:border-emerald-500/15 dark:bg-emerald-500/[0.02] dark:hover:bg-emerald-500/[0.05]"
|
||||
: status === "new" ? "border-gold/30 bg-gold/[0.06] hover:border-gold/50 hover:bg-gold/[0.1] dark:border-gold/20 dark:bg-gold/[0.03] dark:hover:border-gold/40 dark:hover:bg-gold/[0.06]"
|
||||
: "border-neutral-200 bg-neutral-50 hover:border-neutral-300 hover:bg-neutral-100 dark:border-white/10 dark:bg-neutral-800/30 dark:hover:border-white/20 dark:hover:bg-neutral-800/50"
|
||||
}${highlight ? " ring-2 ring-gold/40 animate-[pulse_1s_ease-in-out_1]" : ""}`}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -109,7 +109,7 @@ export function GenericBookingsList<T extends BaseBooking>({
|
||||
<BookingCard status={item.status} highlight={isHighlighted}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
|
||||
<span className="font-medium text-white truncate max-w-[200px]">{item.name}</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-white truncate max-w-[200px]">{item.name}</span>
|
||||
<ContactLinks phone={item.phone} instagram={item.instagram} telegram={item.telegram} />
|
||||
{renderExtra?.(item)}
|
||||
</div>
|
||||
@@ -144,40 +144,54 @@ export function GenericBookingsList<T extends BaseBooking>({
|
||||
const groupCounts = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
|
||||
for (const item of group.items) groupCounts[item.status] = (groupCounts[item.status] || 0) + 1;
|
||||
return (
|
||||
<div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-white/5 opacity-60" : "border-white/10"}`}>
|
||||
<div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-neutral-100 opacity-60 dark:border-white/5" : "border-neutral-200 dark:border-white/10"}`}>
|
||||
<button
|
||||
onClick={() => setExpanded((p) => ({ ...p, [group.key]: !isOpen }))}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors text-left ${group.isArchived ? "bg-neutral-900/50 hover:bg-neutral-800/50" : "bg-neutral-900 hover:bg-neutral-800/80"}`}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors text-left ${group.isArchived ? "bg-neutral-100/50 hover:bg-neutral-200/50 dark:bg-neutral-900/50 dark:hover:bg-neutral-800/50" : "bg-neutral-50 hover:bg-neutral-200/80 dark:bg-neutral-900 dark:hover:bg-neutral-800/80"}`}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
|
||||
{group.sublabel && (
|
||||
<span className={`text-xs font-medium shrink-0 ${group.isArchived ? "text-neutral-500" : "text-gold"}`}>{group.sublabel}</span>
|
||||
)}
|
||||
<span className={`font-medium text-sm truncate ${group.isArchived ? "text-neutral-400" : "text-white"}`}>{group.label}</span>
|
||||
<span className={`font-medium text-sm truncate ${group.isArchived ? "text-neutral-500 dark:text-neutral-400" : "text-neutral-900 dark:text-white"}`}>{group.label}</span>
|
||||
{group.dateBadge && (
|
||||
<span className={`text-[10px] rounded-full px-2 py-0.5 shrink-0 ${
|
||||
group.isArchived ? "text-neutral-600 bg-neutral-800 line-through" : "text-gold bg-gold/10"
|
||||
group.isArchived ? "text-neutral-600 bg-neutral-800 line-through" : "text-amber-700 dark:text-gold bg-gold/10"
|
||||
}`}>
|
||||
{group.dateBadge}
|
||||
</span>
|
||||
)}
|
||||
{group.isArchived && (
|
||||
<span className="text-[10px] text-neutral-600 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">архив</span>
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 shrink-0 dark:text-neutral-600 dark:bg-neutral-800">архив</span>
|
||||
)}
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{group.items.length} чел.</span>
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 shrink-0 dark:bg-neutral-800">{group.items.length} чел.</span>
|
||||
{!group.isArchived && (
|
||||
<div className="flex gap-2 ml-auto text-[10px]">
|
||||
{groupCounts.new > 0 && <span className="text-gold">{groupCounts.new} новых</span>}
|
||||
{groupCounts.contacted > 0 && <span className="text-blue-400">{groupCounts.contacted} связ.</span>}
|
||||
{groupCounts.confirmed > 0 && <span className="text-emerald-400">{groupCounts.confirmed} подтв.</span>}
|
||||
{groupCounts.new > 0 && <span className="text-amber-700 dark:text-gold">{groupCounts.new} новых</span>}
|
||||
{groupCounts.contacted > 0 && <span className="text-blue-600 dark:text-blue-400">{groupCounts.contacted} связ.</span>}
|
||||
{groupCounts.confirmed > 0 && <span className="text-emerald-600 dark:text-emerald-400">{groupCounts.confirmed} подтв.</span>}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-3 pt-1 space-y-2">
|
||||
{group.items.map((item) => renderItem(item, group.isArchived))}
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
{regular.map((item) => renderItem(item, group.isArchived))}
|
||||
{waiting.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<div className="flex-1 h-px bg-amber-500/20" />
|
||||
<span className="text-[10px] text-amber-400 shrink-0">лист ожидания</span>
|
||||
<div className="flex-1 h-px bg-amber-500/20" />
|
||||
</div>
|
||||
{waiting.map((item) => renderItem(item, group.isArchived))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,10 +43,10 @@ export function SearchBar({
|
||||
value={query}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="Поиск по имени или телефону..."
|
||||
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] py-2 pl-9 pr-8 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 py-2 pl-9 pr-8 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/40 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
{query && (
|
||||
<button onClick={clear} className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white">
|
||||
<button onClick={clear} className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-900 dark:hover:text-white">
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface GroupBooking {
|
||||
status: BookingStatus;
|
||||
confirmedDate?: string;
|
||||
confirmedGroup?: string;
|
||||
confirmedHall?: string;
|
||||
confirmedComment?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
@@ -160,43 +161,43 @@ function ConfirmModal({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const selectClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 [color-scheme:dark] disabled:opacity-30 disabled:cursor-not-allowed";
|
||||
const selectClass = "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 [color-scheme:light] disabled:opacity-30 disabled:cursor-not-allowed dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:[color-scheme:dark]";
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={onClose} aria-label="Закрыть" className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
|
||||
<div className="relative w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={onClose} aria-label="Закрыть" className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white">
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
<h3 className="text-base font-bold text-white">Подтвердить запись</h3>
|
||||
<p className="mt-1 text-xs text-neutral-400">{bookingName}</p>
|
||||
<h3 className="text-base font-bold text-neutral-900 dark:text-white">Подтвердить запись</h3>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{bookingName}</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Зал</label>
|
||||
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Зал</label>
|
||||
<select value={hall} onChange={(e) => setHall(e.target.value)} className={selectClass}>
|
||||
<option value="" className="bg-neutral-900">Выберите зал</option>
|
||||
{halls.map((h) => <option key={h} value={h} className="bg-neutral-900">{h}</option>)}
|
||||
<option value="" className="bg-white dark:bg-neutral-900">Выберите зал</option>
|
||||
{halls.map((h) => <option key={h} value={h} className="bg-white dark:bg-neutral-900">{h}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Тренер</label>
|
||||
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Тренер</label>
|
||||
<select value={trainer} onChange={(e) => setTrainer(e.target.value)} disabled={!hall} className={selectClass}>
|
||||
<option value="" className="bg-neutral-900">Выберите тренера</option>
|
||||
{trainers.map((t) => <option key={t} value={t} className="bg-neutral-900">{t}</option>)}
|
||||
<option value="" className="bg-white dark:bg-neutral-900">Выберите тренера</option>
|
||||
{trainers.map((t) => <option key={t} value={t} className="bg-white dark:bg-neutral-900">{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Группа</label>
|
||||
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Группа</label>
|
||||
<select value={group} onChange={(e) => setGroup(e.target.value)} disabled={!trainer} className={selectClass}>
|
||||
<option value="" className="bg-neutral-900">Выберите группу</option>
|
||||
{groups.map((g) => <option key={g.value} value={g.value} className="bg-neutral-900">{g.label}</option>)}
|
||||
<option value="" className="bg-white dark:bg-neutral-900">Выберите группу</option>
|
||||
{groups.map((g) => <option key={g.value} value={g.value} className="bg-white dark:bg-neutral-900">{g.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Дата занятия</label>
|
||||
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Дата занятия</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
@@ -211,14 +212,14 @@ function ConfirmModal({
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
|
||||
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
disabled={!group}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Первое занятие, пробный"
|
||||
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/40 disabled:opacity-30 disabled:cursor-not-allowed dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,18 +283,23 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
|
||||
...b, status: "confirmed" as BookingStatus,
|
||||
confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes,
|
||||
} : b));
|
||||
await Promise.all([
|
||||
adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }),
|
||||
}),
|
||||
data.comment ? adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
|
||||
}) : Promise.resolve(),
|
||||
]);
|
||||
try {
|
||||
await Promise.all([
|
||||
adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }),
|
||||
}),
|
||||
data.comment ? adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
|
||||
}) : Promise.resolve(),
|
||||
]);
|
||||
} catch {
|
||||
// Revert optimistic update on failure
|
||||
setBookings((prev) => prev.map((b) => b.id === confirmingId ? { ...b, ...existing } : b));
|
||||
}
|
||||
setConfirmingId(null);
|
||||
onDataChange?.();
|
||||
}
|
||||
@@ -312,8 +318,8 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
|
||||
onConfirm={(id) => setConfirmingId(id)}
|
||||
renderExtra={(b) => (
|
||||
<>
|
||||
{b.groupInfo && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>}
|
||||
{b.confirmedHall && <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{b.confirmedHall}</span>}
|
||||
{b.groupInfo && <span className="text-xs text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:text-neutral-400 dark:bg-neutral-800">{b.groupInfo}</span>}
|
||||
{b.confirmedHall && <span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{b.confirmedHall}</span>}
|
||||
{(b.confirmedGroup || b.confirmedDate) && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }}
|
||||
@@ -465,11 +471,11 @@ function RemindersTab() {
|
||||
: currentStatus === "coming" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
|
||||
: currentStatus === "cancelled" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
|
||||
: currentStatus === "pending" ? "border-amber-500/15 bg-amber-500/[0.02]"
|
||||
: "border-white/5 bg-neutral-800/30"
|
||||
: "border-neutral-200 bg-neutral-100/30 dark:border-white/5 dark:bg-neutral-800/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm">
|
||||
<span className="font-medium text-white">{item.name}</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-white">{item.name}</span>
|
||||
{item.phone && (
|
||||
<a href={`tel:${item.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||
<Phone size={10} />{item.phone}
|
||||
@@ -536,11 +542,11 @@ function RemindersTab() {
|
||||
const TypeIcon = typeConf.icon;
|
||||
const egStats = countByStatus(eg.items);
|
||||
return (
|
||||
<div key={eg.label} className="rounded-xl border border-white/10 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-900">
|
||||
<div key={eg.label} className="rounded-xl border border-neutral-200 overflow-hidden dark:border-white/10">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-50 dark:bg-neutral-900">
|
||||
<TypeIcon size={13} className={typeConf.color} />
|
||||
<span className="text-sm font-medium text-white">{eg.label}{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-sm font-medium text-neutral-900 dark:text-white">{eg.label}{eg.items[0]?.eventHall ? ` · ${eg.items[0].eventHall}` : ""}</span>
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{eg.items.length} чел.</span>
|
||||
<div className="flex gap-2 ml-auto text-[10px]">
|
||||
{egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>}
|
||||
{egStats.cancelled > 0 && <span className="text-red-400">{egStats.cancelled} не придёт</span>}
|
||||
@@ -596,10 +602,12 @@ function countByStatus(items: { status: string }[]): TabCounts {
|
||||
}
|
||||
|
||||
|
||||
function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
||||
function DashboardSummary({ refreshTrigger, onNavigate, onFilter, activeTab, activeFilter }: {
|
||||
refreshTrigger: number;
|
||||
onNavigate: (tab: Tab) => void;
|
||||
onFilter: (f: BookingFilter) => void;
|
||||
activeTab: Tab;
|
||||
activeFilter: BookingFilter;
|
||||
}) {
|
||||
const [counts, setCounts] = useState<DashboardCounts | null>(null);
|
||||
|
||||
@@ -664,15 +672,15 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
||||
if (c.tab === "reminders") {
|
||||
const total = counts.remindersToday + counts.remindersTomorrow;
|
||||
if (total === 0) return (
|
||||
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40">
|
||||
<div key={c.tab} className="rounded-xl border border-neutral-100 bg-neutral-50 p-3 opacity-40 dark:border-white/5 dark:bg-neutral-900/50">
|
||||
<p className="text-xs text-neutral-500">{c.label}</p>
|
||||
<p className="text-lg font-bold text-neutral-600 mt-1">—</p>
|
||||
<p className="text-lg font-bold text-neutral-400 mt-1 dark:text-neutral-600">—</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<button key={c.tab} onClick={() => onNavigate(c.tab)}
|
||||
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}>
|
||||
<p className="text-xs text-neutral-400">{c.label}</p>
|
||||
className={`rounded-xl border ${c.color} bg-neutral-50 p-3 text-left transition-all hover:bg-neutral-100 hover:scale-[1.02] dark:bg-neutral-900 dark:hover:bg-neutral-800/80`}>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{c.label}</p>
|
||||
<div className="flex items-baseline gap-2 mt-1 flex-wrap">
|
||||
{counts.remindersNotAsked > 0 && (
|
||||
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||
@@ -710,20 +718,25 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
||||
const tc = c.counts!;
|
||||
const total = tc.new + tc.contacted + tc.confirmed + tc.declined;
|
||||
if (total === 0) return (
|
||||
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40">
|
||||
<div key={c.tab} className="rounded-xl border border-neutral-100 bg-neutral-50 p-3 opacity-40 dark:border-white/5 dark:bg-neutral-900/50">
|
||||
<p className="text-xs text-neutral-500">{c.label}</p>
|
||||
<p className="text-lg font-bold text-neutral-600 mt-1">—</p>
|
||||
<p className="text-lg font-bold text-neutral-400 mt-1 dark:text-neutral-600">—</p>
|
||||
</div>
|
||||
);
|
||||
const isActiveCard = activeTab === c.tab;
|
||||
const hl = (status: BookingFilter) =>
|
||||
isActiveCard && activeFilter === status
|
||||
? "rounded-md bg-neutral-200 px-1.5 -mx-1.5 py-0.5 -my-0.5 ring-1 ring-neutral-300 dark:bg-white/10 dark:ring-white/20"
|
||||
: "";
|
||||
return (
|
||||
<button key={c.tab} onClick={() => { onNavigate(c.tab); onFilter("all"); }}
|
||||
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}>
|
||||
<p className="text-xs text-neutral-400">{c.label}</p>
|
||||
className={`rounded-xl border ${c.color} bg-neutral-50 p-3 text-left transition-all hover:bg-neutral-100 hover:scale-[1.02] dark:bg-neutral-900 dark:hover:bg-neutral-800/80`}>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{c.label}</p>
|
||||
<div className="flex items-baseline gap-2 mt-1 flex-wrap">
|
||||
{tc.new > 0 && (
|
||||
<>
|
||||
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("new"); }}>
|
||||
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("new")}`}
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "new" && isActiveCard ? "all" : "new"); }}>
|
||||
<span className="text-lg font-bold text-gold">{tc.new}</span>
|
||||
<span className="text-[10px] text-neutral-500">новых</span>
|
||||
</span>
|
||||
@@ -732,8 +745,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
||||
{tc.contacted > 0 && (
|
||||
<>
|
||||
{tc.new > 0 && <span className="text-neutral-700">·</span>}
|
||||
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("contacted"); }}>
|
||||
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("contacted")}`}
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "contacted" && isActiveCard ? "all" : "contacted"); }}>
|
||||
<span className="text-sm font-medium text-blue-400">{tc.contacted}</span>
|
||||
<span className="text-[10px] text-neutral-500">в работе</span>
|
||||
</span>
|
||||
@@ -742,8 +755,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
||||
{tc.confirmed > 0 && (
|
||||
<>
|
||||
{(tc.new > 0 || tc.contacted > 0) && <span className="text-neutral-700">·</span>}
|
||||
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("confirmed"); }}>
|
||||
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("confirmed")}`}
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "confirmed" && isActiveCard ? "all" : "confirmed"); }}>
|
||||
<span className="text-sm font-medium text-emerald-400">{tc.confirmed}</span>
|
||||
<span className="text-[10px] text-neutral-500">подтв.</span>
|
||||
</span>
|
||||
@@ -752,8 +765,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
||||
{tc.declined > 0 && (
|
||||
<>
|
||||
{(tc.new > 0 || tc.contacted > 0 || tc.confirmed > 0) && <span className="text-neutral-700">·</span>}
|
||||
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("declined"); }}>
|
||||
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("declined")}`}
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "declined" && isActiveCard ? "all" : "declined"); }}>
|
||||
<span className="text-sm font-medium text-red-400">{tc.declined}</span>
|
||||
<span className="text-[10px] text-neutral-500">отказ</span>
|
||||
</span>
|
||||
@@ -884,7 +897,7 @@ function BookingsPageInner() {
|
||||
<button
|
||||
onClick={() => setHallFilter("all")}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
|
||||
hallFilter === "all" ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-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}
|
||||
onClick={() => setHallFilter(hallFilter === hall ? "all" : hall)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
|
||||
hallFilter === hall ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-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}
|
||||
@@ -916,10 +929,10 @@ function BookingsPageInner() {
|
||||
<BookingCard key={`${r.type}-${r.id}`} status={r.status as BookingStatus}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{TYPE_LABELS[r.type] || r.type}</span>
|
||||
<span className="font-medium text-white">{r.name}</span>
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{TYPE_LABELS[r.type] || r.type}</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-white">{r.name}</span>
|
||||
<ContactLinks phone={r.phone} instagram={r.instagram} telegram={r.telegram} />
|
||||
{r.groupLabel && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{r.groupLabel}</span>}
|
||||
{r.groupLabel && <span className="text-xs text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:text-neutral-400 dark:bg-neutral-800">{r.groupLabel}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-neutral-600 text-xs">{fmtDate(r.createdAt)}</span>
|
||||
@@ -940,16 +953,27 @@ function BookingsPageInner() {
|
||||
) : (
|
||||
<>
|
||||
{/* Dashboard — what needs attention */}
|
||||
<DashboardSummary refreshTrigger={dashboardKey + refreshKey} onNavigate={setTab} onFilter={setStatusFilter} />
|
||||
<DashboardSummary refreshTrigger={dashboardKey + refreshKey} onNavigate={setTab} onFilter={setStatusFilter} activeTab={tab} activeFilter={statusFilter} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mt-5 flex border-b border-white/10">
|
||||
{/* Tabs — select on mobile, tabs on desktop */}
|
||||
<div className="mt-5 sm:hidden">
|
||||
<select
|
||||
value={tab}
|
||||
onChange={(e) => setTab(e.target.value as Tab)}
|
||||
className="w-full rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-900 outline-none focus:border-gold/40 transition-colors dark:border-white/10 dark:bg-neutral-900 dark:text-white dark:[color-scheme:dark]"
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
<option key={t.key} value={t.key}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-5 hidden sm:flex border-b border-neutral-200 dark:border-white/10">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
||||
tab === t.key ? "text-gold" : "text-neutral-400 hover:text-white"
|
||||
className={`shrink-0 px-4 py-2.5 text-sm font-medium transition-colors relative whitespace-nowrap ${
|
||||
tab === t.key ? "text-gold" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
@@ -963,9 +987,9 @@ function BookingsPageInner() {
|
||||
{/* Tab content */}
|
||||
<div className="mt-4">
|
||||
{tab === "reminders" && <RemindersTab key={refreshKey} />}
|
||||
{tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />}
|
||||
{tab === "classes" && <GroupBookingsTab key={refreshKey} filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||
{tab === "master-classes" && <McRegistrationsTab key={refreshKey} filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||
{tab === "open-day" && <OpenDayBookingsTab key={refreshKey} filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -973,7 +997,7 @@ function BookingsPageInner() {
|
||||
<AddBookingModal
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onAdded={() => { setRefreshKey((k) => k + 1); refreshDashboard(); }}
|
||||
onAdded={() => { setStatusFilter("all"); setRefreshKey((k) => k + 1); refreshDashboard(); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SHORT_DAYS } from "@/lib/formatting";
|
||||
|
||||
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
|
||||
export type BookingFilter = "all" | BookingStatus;
|
||||
|
||||
@@ -12,20 +14,26 @@ export interface BaseBooking {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const SHORT_DAYS: Record<string, string> = {
|
||||
"Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ",
|
||||
"Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС",
|
||||
};
|
||||
export { SHORT_DAYS };
|
||||
|
||||
export const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [
|
||||
{ key: "new", label: "Новая", color: "text-gold", bg: "bg-gold/10", border: "border-gold/30" },
|
||||
{ key: "contacted", label: "Связались", color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
|
||||
{ key: "confirmed", label: "Подтверждено", color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" },
|
||||
{ key: "declined", label: "Отказ", color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
|
||||
{ key: "new", label: "Новая", color: "text-amber-700 dark:text-gold", bg: "bg-gold/10", border: "border-gold/30" },
|
||||
{ key: "contacted", label: "Связались", color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
|
||||
{ key: "confirmed", label: "Подтверждено", color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" },
|
||||
{ key: "declined", label: "Отказ", color: "text-red-600 dark:text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
|
||||
];
|
||||
|
||||
export function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("ru-RU");
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const sameYear = d.getFullYear() === now.getFullYear();
|
||||
const date = d.toLocaleDateString("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
...(sameYear ? {} : { year: "numeric" }),
|
||||
});
|
||||
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||
return `${date}, ${time}`;
|
||||
}
|
||||
|
||||
export function countStatuses(items: { status: string }[]): Record<string, number> {
|
||||
|
||||
@@ -2,23 +2,66 @@
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, TextareaField } from "../_components/FormField";
|
||||
import { InputField, TextareaField, RichTextarea } from "../_components/FormField";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
import { icons, type LucideIcon } from "lucide-react";
|
||||
import { ImageCropField } from "../_components/ImageCropField";
|
||||
import {
|
||||
icons, type LucideIcon,
|
||||
Flame, Heart, HeartPulse, Star, Sparkles, Music, Zap, Crown,
|
||||
Dumbbell, Wind, Moon, Sun, Ribbon, Gem, Feather, CircleDot,
|
||||
Activity, Drama, PersonStanding, Footprints, PartyPopper, Flower2,
|
||||
Waves, Eye, Orbit, Brush, Palette, HandMetal, Theater,
|
||||
} from "lucide-react";
|
||||
|
||||
// Curated icons for dance school
|
||||
const CURATED_ICONS: { key: string; Icon: LucideIcon; label: string }[] = [
|
||||
{ key: "flame", Icon: Flame, label: "Flame" },
|
||||
{ key: "heart", Icon: Heart, label: "Heart" },
|
||||
{ key: "heart-pulse", Icon: HeartPulse, label: "HeartPulse" },
|
||||
{ key: "star", Icon: Star, label: "Star" },
|
||||
{ key: "sparkles", Icon: Sparkles, label: "Sparkles" },
|
||||
{ key: "music", Icon: Music, label: "Music" },
|
||||
{ key: "zap", Icon: Zap, label: "Zap" },
|
||||
{ key: "crown", Icon: Crown, label: "Crown" },
|
||||
{ key: "dumbbell", Icon: Dumbbell, label: "Dumbbell" },
|
||||
{ key: "wind", Icon: Wind, label: "Wind" },
|
||||
{ key: "moon", Icon: Moon, label: "Moon" },
|
||||
{ key: "sun", Icon: Sun, label: "Sun" },
|
||||
{ key: "ribbon", Icon: Ribbon, label: "Ribbon" },
|
||||
{ key: "gem", Icon: Gem, label: "Gem" },
|
||||
{ key: "feather", Icon: Feather, label: "Feather" },
|
||||
{ key: "circle-dot", Icon: CircleDot, label: "CircleDot" },
|
||||
{ key: "activity", Icon: Activity, label: "Activity" },
|
||||
{ key: "drama", Icon: Drama, label: "Drama" },
|
||||
{ key: "person-standing", Icon: PersonStanding, label: "PersonStanding" },
|
||||
{ key: "footprints", Icon: Footprints, label: "Footprints" },
|
||||
{ key: "party-popper", Icon: PartyPopper, label: "PartyPopper" },
|
||||
{ key: "flower-2", Icon: Flower2, label: "Flower" },
|
||||
{ key: "waves", Icon: Waves, label: "Waves" },
|
||||
{ key: "eye", Icon: Eye, label: "Eye" },
|
||||
{ key: "orbit", Icon: Orbit, label: "Orbit" },
|
||||
{ key: "brush", Icon: Brush, label: "Brush" },
|
||||
{ key: "palette", Icon: Palette, label: "Palette" },
|
||||
{ key: "hand-metal", Icon: HandMetal, label: "HandMetal" },
|
||||
{ key: "theater", Icon: Theater, label: "Theater" },
|
||||
];
|
||||
|
||||
// PascalCase "HeartPulse" → kebab "heart-pulse"
|
||||
function toKebab(name: string) {
|
||||
return name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
||||
}
|
||||
|
||||
// All icons as { key: kebab-name, Icon: component, label: PascalCase }
|
||||
// Full icon list for search fallback
|
||||
const ALL_ICONS = Object.entries(icons).map(([name, Icon]) => ({
|
||||
key: toKebab(name),
|
||||
Icon: Icon as LucideIcon,
|
||||
label: name,
|
||||
}));
|
||||
|
||||
const ICON_BY_KEY = Object.fromEntries(ALL_ICONS.map((i) => [i.key, i]));
|
||||
const ICON_BY_KEY = Object.fromEntries([
|
||||
...CURATED_ICONS.map((i) => [i.key, i]),
|
||||
...ALL_ICONS.map((i) => [i.key, i]),
|
||||
]);
|
||||
|
||||
function IconPicker({
|
||||
value,
|
||||
@@ -46,9 +89,12 @@ function IconPicker({
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return ALL_ICONS.slice(0, 60);
|
||||
if (!search) return CURATED_ICONS;
|
||||
const q = search.toLowerCase();
|
||||
return ALL_ICONS.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 60);
|
||||
// Search curated first, then all icons
|
||||
const curated = CURATED_ICONS.filter((i) => i.label.toLowerCase().includes(q));
|
||||
const rest = ALL_ICONS.filter((i) => i.label.toLowerCase().includes(q) && !curated.some((c) => c.key === i.key));
|
||||
return [...curated, ...rest].slice(0, 40);
|
||||
}, [search]);
|
||||
|
||||
const SelectedIcon = selected?.Icon;
|
||||
@@ -63,8 +109,8 @@ function IconPicker({
|
||||
setSearch("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 rounded-lg border bg-neutral-800 px-4 py-2.5 text-left text-white outline-none transition-colors ${
|
||||
open ? "border-gold" : "border-white/10"
|
||||
className={`w-full flex items-center gap-2.5 rounded-lg border bg-neutral-100 px-4 py-2.5 text-left text-neutral-900 outline-none transition-colors dark:bg-neutral-800 dark:text-white ${
|
||||
open ? "border-gold" : "border-neutral-200 dark:border-white/10"
|
||||
}`}
|
||||
>
|
||||
{SelectedIcon ? (
|
||||
@@ -72,21 +118,21 @@ function IconPicker({
|
||||
<SelectedIcon size={16} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-white/10 text-neutral-500">?</span>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-neutral-200 text-neutral-500 dark:bg-white/10">?</span>
|
||||
)}
|
||||
<span className="text-sm">{selected?.label || value}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/10 dark:bg-neutral-800">
|
||||
<div className="p-2 pb-0">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск иконки... (flame, heart, star...)"
|
||||
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
|
||||
placeholder="Поиск..."
|
||||
className="w-full rounded-md border border-neutral-200 bg-neutral-100 px-3 py-1.5 text-sm text-neutral-900 outline-none focus:border-gold/50 placeholder:text-neutral-400 dark:border-white/10 dark:bg-neutral-900 dark:text-white dark:placeholder:text-neutral-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 max-h-56 overflow-y-auto">
|
||||
@@ -107,7 +153,7 @@ function IconPicker({
|
||||
className={`flex flex-col items-center gap-0.5 rounded-lg p-2 transition-colors ${
|
||||
key === value
|
||||
? "bg-gold/20 text-gold-light"
|
||||
: "text-neutral-400 hover:bg-white/5 hover:text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Icon size={20} />
|
||||
@@ -150,13 +196,16 @@ interface ClassesData {
|
||||
icon: string;
|
||||
detailedDescription?: string;
|
||||
images?: string[];
|
||||
imageFocalX?: number;
|
||||
imageFocalY?: number;
|
||||
imageZoom?: number;
|
||||
color?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export default function ClassesEditorPage() {
|
||||
return (
|
||||
<SectionEditor<ClassesData> sectionKey="classes" title="Направления">
|
||||
<SectionEditor<ClassesData> sectionKey="classes" title="Направления" defaultData={{ items: [] }}>
|
||||
{(data, update) => (
|
||||
<>
|
||||
<InputField
|
||||
@@ -188,18 +237,21 @@ export default function ClassesEditorPage() {
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{COLOR_SWATCHES.map((c) => {
|
||||
const isUsed = data.items.some(
|
||||
const isSelected = item.color === c.value;
|
||||
const isUsed = !isSelected && data.items.some(
|
||||
(other) => other !== item && other.color === c.value
|
||||
);
|
||||
if (isUsed) return null;
|
||||
return (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
disabled={isUsed}
|
||||
onClick={() => updateItem({ ...item, color: c.value })}
|
||||
className={`h-6 w-6 rounded-full ${c.bg} transition-all ${
|
||||
item.color === c.value
|
||||
isSelected
|
||||
? "ring-2 ring-white ring-offset-1 ring-offset-neutral-900 scale-110"
|
||||
: isUsed
|
||||
? "opacity-15 cursor-not-allowed"
|
||||
: "opacity-50 hover:opacity-100"
|
||||
}`}
|
||||
/>
|
||||
@@ -207,13 +259,21 @@ export default function ClassesEditorPage() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<ImageCropField
|
||||
image={item.images?.[0] || ""}
|
||||
focalX={item.imageFocalX ?? 50}
|
||||
focalY={item.imageFocalY ?? 50}
|
||||
zoom={item.imageZoom ?? 1}
|
||||
folder="classes"
|
||||
onChange={(d) => updateItem({ ...item, images: d.image ? [d.image] : [], imageFocalX: d.focalX, imageFocalY: d.focalY, imageZoom: d.zoom })}
|
||||
/>
|
||||
<TextareaField
|
||||
label="Краткое описание"
|
||||
value={item.description}
|
||||
onChange={(v) => updateItem({ ...item, description: v })}
|
||||
rows={2}
|
||||
/>
|
||||
<TextareaField
|
||||
<RichTextarea
|
||||
label="Подробное описание"
|
||||
value={item.detailedDescription || ""}
|
||||
onChange={(v) =>
|
||||
@@ -231,6 +291,8 @@ export default function ClassesEditorPage() {
|
||||
images: [],
|
||||
})}
|
||||
addLabel="Добавить направление"
|
||||
collapsible
|
||||
getItemTitle={(item) => item.name || "Без названия"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,54 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Plus, X, AlertCircle, Check, Loader2 } from "lucide-react";
|
||||
import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, TextareaField } from "../_components/FormField";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
import { InputField } from "../_components/FormField";
|
||||
import { CollapsibleSection } from "../_components/CollapsibleSection";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import type { ContactInfo } from "@/types/content";
|
||||
|
||||
// --- Phone input with mask ---
|
||||
function PhoneField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
function formatPhone(raw: string): string {
|
||||
const digits = raw.replace(/\D/g, "").slice(0, 12);
|
||||
if (digits.length === 0) return "+375 ";
|
||||
let result = "+";
|
||||
for (let i = 0; i < digits.length; i++) {
|
||||
if (i === 3) result += " (";
|
||||
if (i === 5) result += ") ";
|
||||
if (i === 8) result += "-";
|
||||
if (i === 10) result += "-";
|
||||
result += digits[i];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const formatted = formatPhone(e.target.value);
|
||||
onChange(formatted);
|
||||
}
|
||||
|
||||
const digits = (value ?? "").replace(/\D/g, "");
|
||||
const isComplete = digits.length === 12;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Телефон</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="tel"
|
||||
value={value ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="+375 (XX) XXX-XX-XX"
|
||||
className={`w-full rounded-lg border bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 ${
|
||||
value && !isComplete ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
{isComplete && (
|
||||
<Check size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
{value && !isComplete && (
|
||||
<p className="mt-1 text-xs text-red-400">Формат: +375 (XX) XXX-XX-XX — данные не сохранятся</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Instagram field like team page (username with @ prefix + validation) ---
|
||||
function InstagramField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const [status, setStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle");
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Extract username from URL or @-prefixed input
|
||||
function extractUsername(raw: string): string {
|
||||
if (!raw) return "";
|
||||
return raw
|
||||
.replace(/^https?:\/\/(www\.)?instagram\.com\//, "")
|
||||
.replace(/\/$/, "")
|
||||
.replace(/^@/, "");
|
||||
}
|
||||
|
||||
const validateUsername = useCallback((username: string) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (!username) { setStatus("idle"); return; }
|
||||
setStatus("checking");
|
||||
timerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await adminFetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`);
|
||||
const result = await res.json();
|
||||
setStatus(result.valid ? "valid" : "invalid");
|
||||
} catch {
|
||||
setStatus("idle");
|
||||
}
|
||||
}, 800);
|
||||
}, []);
|
||||
|
||||
// On mount, if value exists, mark as valid (trusted existing data)
|
||||
const initializedRef = useRef(false);
|
||||
if (value && !initializedRef.current) {
|
||||
initializedRef.current = true;
|
||||
if (status === "idle") setStatus("valid");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Instagram</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-500 text-sm select-none">@</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
const username = extractUsername(e.target.value);
|
||||
onChange(username);
|
||||
validateUsername(username);
|
||||
}}
|
||||
placeholder="blackheartdancehouse"
|
||||
className={`w-full rounded-lg border bg-neutral-100 pl-8 pr-10 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 ${
|
||||
status === "invalid"
|
||||
? "border-red-500 focus:border-red-500"
|
||||
: status === "valid"
|
||||
? "border-green-500/50 focus:border-green-500"
|
||||
: "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{status === "checking" && <Loader2 size={14} className="animate-spin text-neutral-400" />}
|
||||
{status === "valid" && <Check size={14} className="text-green-400" />}
|
||||
{status === "invalid" && <AlertCircle size={14} className="text-red-400" />}
|
||||
</span>
|
||||
</div>
|
||||
{status === "invalid" && (
|
||||
<p className="mt-1 text-xs text-red-400">Аккаунт не найден</p>
|
||||
)}
|
||||
{value && status !== "invalid" && status !== "checking" && (
|
||||
<a
|
||||
href={`https://instagram.com/${value}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-block text-xs text-neutral-500 hover:text-gold transition-colors"
|
||||
>
|
||||
instagram.com/{value} ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Compact address list ---
|
||||
function AddressList({ items, onChange }: { items: string[]; onChange: (items: string[]) => void }) {
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
function add() {
|
||||
const val = draft.trim();
|
||||
if (!val) return;
|
||||
onChange([...items, val]);
|
||||
setDraft("");
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function update(index: number, value: string) {
|
||||
onChange(items.map((item, i) => (i === index ? value : item)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((addr, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={addr}
|
||||
onChange={(e) => update(i, e.target.value)}
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(i)}
|
||||
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||
onBlur={add}
|
||||
placeholder="Добавить адрес..."
|
||||
className="flex-1 rounded-lg border border-dashed border-neutral-300 bg-neutral-100/50 px-4 py-2.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/50 transition-colors dark:border-white/15 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
disabled={!draft.trim()}
|
||||
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-gold transition-colors disabled:opacity-30"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isPhoneValid(phone: string | undefined): boolean {
|
||||
if (!phone) return true; // empty is ok
|
||||
return (phone.replace(/\D/g, "")).length === 12;
|
||||
}
|
||||
|
||||
export default function ContactEditorPage() {
|
||||
return (
|
||||
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты">
|
||||
<SectionEditor<ContactInfo>
|
||||
sectionKey="contact"
|
||||
title="Контакты"
|
||||
defaultData={{ addresses: [], instagram: "" }}
|
||||
validate={(data) => isPhoneValid(data.phone)}
|
||||
>
|
||||
{(data, update) => (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<InputField
|
||||
label="Заголовок секции"
|
||||
value={data.title}
|
||||
onChange={(v) => update({ ...data, title: v })}
|
||||
/>
|
||||
<InputField
|
||||
label="Телефон"
|
||||
value={data.phone}
|
||||
onChange={(v) => update({ ...data, phone: v })}
|
||||
type="tel"
|
||||
/>
|
||||
<InputField
|
||||
label="Instagram"
|
||||
value={data.instagram}
|
||||
onChange={(v) => update({ ...data, instagram: v })}
|
||||
type="url"
|
||||
/>
|
||||
<InputField
|
||||
label="Часы работы"
|
||||
value={data.workingHours}
|
||||
onChange={(v) => update({ ...data, workingHours: v })}
|
||||
/>
|
||||
<ArrayEditor
|
||||
label="Адреса"
|
||||
items={data.addresses}
|
||||
onChange={(addresses) => update({ ...data, addresses })}
|
||||
renderItem={(addr, _i, updateItem) => (
|
||||
<InputField label="Адрес" value={addr} onChange={updateItem} />
|
||||
)}
|
||||
createItem={() => ""}
|
||||
addLabel="Добавить адрес"
|
||||
/>
|
||||
<TextareaField
|
||||
label="URL карты (Yandex Maps iframe)"
|
||||
value={data.mapEmbedUrl}
|
||||
onChange={(v) => update({ ...data, mapEmbedUrl: v })}
|
||||
rows={2}
|
||||
/>
|
||||
</>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<PhoneField
|
||||
value={data.phone}
|
||||
onChange={(v) => update({ ...data, phone: v })}
|
||||
/>
|
||||
<InstagramField
|
||||
value={(data.instagram ?? "").replace(/^https?:\/\/(www\.)?instagram\.com\//, "").replace(/\/$/, "")}
|
||||
onChange={(username) => update({ ...data, instagram: username ? `https://instagram.com/${username}` : "" })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CollapsibleSection title="Адреса">
|
||||
<AddressList
|
||||
items={data.addresses ?? []}
|
||||
onChange={(addresses) => update({ ...data, addresses })}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)}
|
||||
</SectionEditor>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ interface FAQData {
|
||||
|
||||
export default function FAQEditorPage() {
|
||||
return (
|
||||
<SectionEditor<FAQData> sectionKey="faq" title="FAQ">
|
||||
<SectionEditor<FAQData> sectionKey="faq" title="FAQ" defaultData={{ items: [] }}>
|
||||
{(data, update) => (
|
||||
<>
|
||||
<InputField
|
||||
@@ -23,6 +23,8 @@ export default function FAQEditorPage() {
|
||||
label="Вопросы и ответы"
|
||||
items={data.items}
|
||||
onChange={(items) => update({ ...data, items })}
|
||||
collapsible
|
||||
getItemTitle={(item) => item.question || "Без вопроса"}
|
||||
renderItem={(item, _i, updateItem) => (
|
||||
<div className="space-y-3">
|
||||
<InputField
|
||||
|
||||
@@ -6,7 +6,7 @@ import { InputField } from "../_components/FormField";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { Upload, X, Loader2, Smartphone, Monitor, Star } from "lucide-react";
|
||||
|
||||
const MAX_VIDEO_SIZE_MB = 8;
|
||||
const MAX_VIDEO_SIZE_MB = 10;
|
||||
const MAX_VIDEO_SIZE_BYTES = MAX_VIDEO_SIZE_MB * 1024 * 1024;
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -67,7 +67,7 @@ function VideoSlot({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-300">{label}</span>
|
||||
{isCenter && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[#c9a96e]/15 px-2 py-0.5 text-[10px] font-medium text-[#c9a96e]">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gold/15 px-2 py-0.5 text-[10px] font-medium text-gold">
|
||||
<Smartphone size={10} />
|
||||
мобильная версия
|
||||
</span>
|
||||
@@ -79,7 +79,7 @@ function VideoSlot({
|
||||
{src ? (
|
||||
<div
|
||||
className={`group relative overflow-hidden rounded-lg border ${
|
||||
isCenter ? "border-[#c9a96e]/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700"
|
||||
isCenter ? "border-gold/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700"
|
||||
}`}
|
||||
onMouseEnter={() => videoRef.current?.play()}
|
||||
onMouseLeave={() => { videoRef.current?.pause(); }}
|
||||
@@ -104,7 +104,7 @@ function VideoSlot({
|
||||
)}
|
||||
</div>
|
||||
{isCenter && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-[#c9a96e]/90 px-2 py-0.5 text-[10px] font-bold text-black">
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-gold/90 px-2 py-0.5 text-[10px] font-bold text-black">
|
||||
<Star size={10} fill="currentColor" />
|
||||
MAIN
|
||||
</div>
|
||||
@@ -115,6 +115,7 @@ function VideoSlot({
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
aria-label={`Удалить видео: ${label}`}
|
||||
className="absolute top-2 right-2 rounded-full bg-black/70 p-1.5 text-neutral-400 opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
|
||||
title="Удалить"
|
||||
>
|
||||
@@ -127,7 +128,7 @@ function VideoSlot({
|
||||
disabled={uploading}
|
||||
className={`flex aspect-[9/16] w-full items-center justify-center rounded-lg border-2 border-dashed transition-colors disabled:opacity-50 ${
|
||||
isCenter
|
||||
? "border-[#c9a96e]/30 text-[#c9a96e]/50 hover:border-[#c9a96e]/60 hover:text-[#c9a96e]"
|
||||
? "border-gold/30 text-gold/50 hover:border-gold/60 hover:text-gold"
|
||||
: "border-neutral-700 text-neutral-500 hover:border-neutral-500 hover:text-neutral-300"
|
||||
}`}
|
||||
>
|
||||
@@ -163,7 +164,7 @@ function VideoSizeInfo({ totalSize, totalMb, rating }: { totalSize: number; tota
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full text-left rounded-lg bg-neutral-800/50 px-3 py-2 transition-colors hover:bg-neutral-800/80"
|
||||
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>
|
||||
@@ -208,10 +209,8 @@ function VideoManager({
|
||||
const syncToParent = useCallback(
|
||||
(updated: (string | null)[]) => {
|
||||
setSlots(updated);
|
||||
// Only propagate when all 3 are filled
|
||||
if (updated.every((s) => s !== null)) {
|
||||
onChange(updated as string[]);
|
||||
}
|
||||
// Save all 3 slots (empty string for unfilled) to preserve positions
|
||||
onChange(updated.map((s) => s || ""));
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
@@ -232,7 +231,7 @@ function VideoManager({
|
||||
});
|
||||
}, [slots]);
|
||||
|
||||
const totalSize = fileSizes.reduce((sum, s) => sum + (s || 0), 0);
|
||||
const totalSize = fileSizes.reduce((sum: number, s) => sum + (s || 0), 0);
|
||||
const totalMb = totalSize / (1024 * 1024);
|
||||
|
||||
function getLoadRating(mb: number): { label: string; color: string } {
|
||||
@@ -245,10 +244,10 @@ function VideoManager({
|
||||
async function handleUpload(idx: number, file: File) {
|
||||
if (file.size > MAX_VIDEO_SIZE_BYTES) {
|
||||
const sizeMb = (file.size / (1024 * 1024)).toFixed(1);
|
||||
setSizeWarning(`Видео ${sizeMb} МБ — рекомендуем до ${MAX_VIDEO_SIZE_MB} МБ для быстрой загрузки`);
|
||||
} else {
|
||||
setSizeWarning(null);
|
||||
alert(`Видео ${sizeMb} МБ — максимум ${MAX_VIDEO_SIZE_MB} МБ. Сожмите видео и попробуйте снова.`);
|
||||
return;
|
||||
}
|
||||
setSizeWarning(null);
|
||||
setUploadingIdx(idx);
|
||||
try {
|
||||
const form = new FormData();
|
||||
@@ -259,14 +258,21 @@ function VideoManager({
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
alert(err.error || "Ошибка загрузки");
|
||||
const text = await res.text();
|
||||
let msg = "Ошибка загрузки";
|
||||
try {
|
||||
const err = JSON.parse(text);
|
||||
msg = err.error || msg;
|
||||
} catch { /* empty response */ }
|
||||
alert(`${msg} (${res.status})`);
|
||||
return;
|
||||
}
|
||||
const { path } = await res.json();
|
||||
const updated = [...slots];
|
||||
updated[idx] = path;
|
||||
syncToParent(updated);
|
||||
} catch (e) {
|
||||
alert(`Ошибка сети: ${e instanceof Error ? e.message : "попробуйте снова"}`);
|
||||
} finally {
|
||||
setUploadingIdx(null);
|
||||
}
|
||||
@@ -275,8 +281,7 @@ function VideoManager({
|
||||
function handleRemove(idx: number) {
|
||||
const updated = [...slots];
|
||||
updated[idx] = null;
|
||||
setSlots(updated);
|
||||
// Don't propagate incomplete state — keep old saved videos in DB
|
||||
syncToParent(updated);
|
||||
}
|
||||
|
||||
const allFilled = slots.every((s) => s !== null);
|
||||
@@ -300,7 +305,7 @@ function VideoManager({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-3 max-w-3xl">
|
||||
{SLOTS.map((slot, i) => (
|
||||
<VideoSlot
|
||||
key={slot.key}
|
||||
@@ -350,17 +355,17 @@ export default function HeroEditorPage() {
|
||||
|
||||
<InputField
|
||||
label="Заголовок"
|
||||
value={data.headline}
|
||||
value={data.headline || ""}
|
||||
onChange={(v) => update({ ...data, headline: v })}
|
||||
/>
|
||||
<InputField
|
||||
label="Подзаголовок"
|
||||
value={data.subheadline}
|
||||
value={data.subheadline || ""}
|
||||
onChange={(v) => update({ ...data, subheadline: v })}
|
||||
/>
|
||||
<InputField
|
||||
label="Текст кнопки"
|
||||
value={data.ctaText}
|
||||
value={data.ctaText || ""}
|
||||
onChange={(v) => update({ ...data, ctaText: v })}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Sparkles,
|
||||
@@ -23,22 +24,25 @@ import {
|
||||
ChevronLeft,
|
||||
ClipboardList,
|
||||
DoorOpen,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/admin", label: "Дашборд", icon: LayoutDashboard },
|
||||
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe },
|
||||
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
|
||||
{ href: "/admin/popups", label: "Формы записи", icon: MessageSquare },
|
||||
// Sections follow user-side order: Hero → About → Classes → Team → OpenDay → Schedule → Pricing → MC → News → FAQ → Contact
|
||||
{ href: "/admin/hero", label: "Главный экран", icon: Sparkles },
|
||||
{ href: "/admin/about", label: "О студии", icon: FileText },
|
||||
{ href: "/admin/team", label: "Команда", icon: Users },
|
||||
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
|
||||
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
|
||||
{ href: "/admin/team", label: "Команда", icon: Users },
|
||||
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen },
|
||||
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
|
||||
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
|
||||
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
||||
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
|
||||
{ href: "/admin/news", label: "Новости", icon: Newspaper },
|
||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
||||
{ href: "/admin/contact", label: "Контакты", icon: Phone },
|
||||
];
|
||||
|
||||
@@ -53,17 +57,19 @@ export default function AdminLayout({
|
||||
const [unreadTotal, setUnreadTotal] = useState(0);
|
||||
const isLoginPage = pathname === "/admin/login";
|
||||
|
||||
// Fetch unread counts — poll every 10s
|
||||
// Fetch unread counts — poll every 10s, stop after 3 consecutive failures
|
||||
useEffect(() => {
|
||||
if (isLoginPage) return;
|
||||
let failures = 0;
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
function fetchCounts() {
|
||||
adminFetch("/api/admin/unread-counts")
|
||||
.then((r) => r.json())
|
||||
.then((data: { total: number }) => setUnreadTotal(data.total))
|
||||
.catch(() => {});
|
||||
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
||||
.then((data: { total: number }) => { setUnreadTotal(data.total); failures = 0; })
|
||||
.catch(() => { failures++; if (failures >= 3 && interval) clearInterval(interval); });
|
||||
}
|
||||
fetchCounts();
|
||||
const interval = setInterval(fetchCounts, 10000);
|
||||
interval = setInterval(fetchCounts, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isLoginPage]);
|
||||
|
||||
@@ -83,7 +89,7 @@ export default function AdminLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-neutral-950 text-white">
|
||||
<div className="flex min-h-screen bg-neutral-50 text-neutral-900 dark:bg-neutral-950 dark:text-white">
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
@@ -94,23 +100,24 @@ export default function AdminLayout({
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-white/10 bg-neutral-900 transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
|
||||
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-neutral-200 bg-white dark:border-white/10 dark:bg-neutral-900 transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-white/10 px-5 py-4">
|
||||
<Link href="/admin" className="text-lg font-bold">
|
||||
BLACK HEART
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="lg:hidden text-neutral-400 hover:text-white"
|
||||
aria-label="Закрыть меню"
|
||||
className="lg:hidden text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
|
||||
<nav aria-label="Навигация панели управления" className="flex-1 overflow-y-auto p-3 space-y-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
@@ -122,13 +129,13 @@ export default function AdminLayout({
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors ${
|
||||
active
|
||||
? "bg-gold/10 text-gold font-medium"
|
||||
: "text-neutral-400 hover:text-white hover:bg-white/5"
|
||||
: "text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{item.label}
|
||||
{item.href === "/admin/bookings" && unreadTotal > 0 && (
|
||||
<span className="ml-auto rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||
<span aria-label={`${unreadTotal} непрочитанных`} className="ml-auto rounded-full bg-red-500 text-white text-xs font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||
{unreadTotal > 99 ? "99+" : unreadTotal}
|
||||
</span>
|
||||
)}
|
||||
@@ -137,18 +144,22 @@ export default function AdminLayout({
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-white/10 p-3 space-y-1">
|
||||
<div className="border-t border-neutral-200 dark:border-white/10 p-3 space-y-1">
|
||||
<div className="flex items-center justify-between px-3 py-1">
|
||||
<span className="text-xs text-neutral-400 dark:text-neutral-500">Тема</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
target="_blank"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
Открыть сайт
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-red-400 hover:bg-white/5 transition-colors"
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-500 hover:text-red-500 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-red-400 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Выйти
|
||||
@@ -159,14 +170,17 @@ export default function AdminLayout({
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Top bar (mobile) */}
|
||||
<header className="flex items-center gap-3 border-b border-white/10 px-4 py-3 lg:hidden">
|
||||
<header className="sticky top-0 z-30 flex items-center gap-3 border-b border-neutral-200 bg-white dark:border-white/10 dark:bg-neutral-950 px-4 py-3 lg:hidden">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="text-neutral-400 hover:text-white"
|
||||
aria-label="Открыть меню"
|
||||
aria-expanded={sidebarOpen}
|
||||
className="text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<a href="/admin" className="font-bold hover:text-gold transition-colors">BLACK HEART</a>
|
||||
<a href="/admin" className="font-bold hover:text-gold transition-colors flex-1">BLACK HEART</a>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-4 sm:p-6 lg:p-8">{children}</main>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
@@ -34,33 +36,45 @@ export default function AdminLoginPage() {
|
||||
}
|
||||
|
||||
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
|
||||
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">
|
||||
<h1 className="text-2xl font-bold text-white">BLACK HEART</h1>
|
||||
<p className="mt-1 text-sm text-neutral-400">Панель управления</p>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">BLACK HEART</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Панель управления</p>
|
||||
</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>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
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"
|
||||
placeholder="Введите пароль"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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="Введите пароль"
|
||||
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>
|
||||
|
||||
{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
|
||||
|
||||
@@ -1,45 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
|
||||
import { InputField, TextareaField, RichTextarea, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
|
||||
import { ImageCropField } from "../_components/ImageCropField";
|
||||
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 type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
||||
|
||||
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
|
||||
// --- Helpers ---
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<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>
|
||||
(item.title || "").toLowerCase().includes(q) ||
|
||||
(item.trainer || "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
title: string;
|
||||
successMessage?: string;
|
||||
waitingListText?: string;
|
||||
items: MasterClassItem[];
|
||||
}
|
||||
|
||||
|
||||
// --- Location Select ---
|
||||
function LocationSelect({
|
||||
value,
|
||||
@@ -64,7 +68,7 @@ function LocationSelect({
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
active
|
||||
? "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" />}
|
||||
@@ -94,6 +98,13 @@ function calcDurationText(startTime: string, endTime: string): string {
|
||||
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({
|
||||
slots,
|
||||
onChange,
|
||||
@@ -125,48 +136,61 @@ function SlotsField({
|
||||
<div className="space-y-2">
|
||||
{slots.map((slot, i) => {
|
||||
const dur = calcDurationText(slot.startTime, slot.endTime);
|
||||
const timeError = hasTimeError(slot.startTime, slot.endTime);
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2 flex-wrap">
|
||||
<input
|
||||
type="date"
|
||||
value={slot.date}
|
||||
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] ${
|
||||
!slot.date ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||
}`}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={slot.startTime}
|
||||
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]"
|
||||
/>
|
||||
<span className="text-neutral-500 text-xs">–</span>
|
||||
<input
|
||||
type="time"
|
||||
value={slot.endTime}
|
||||
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]"
|
||||
/>
|
||||
{dur && (
|
||||
<span className="text-[11px] text-neutral-500 bg-neutral-800/50 rounded-full px-2 py-0.5">
|
||||
{dur}
|
||||
</span>
|
||||
<div key={i}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<input
|
||||
type="date"
|
||||
value={slot.date}
|
||||
onChange={(e) => updateSlot(i, { date: e.target.value })}
|
||||
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-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={slot.startTime}
|
||||
onChange={(e) => updateSlot(i, { startTime: e.target.value })}
|
||||
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>
|
||||
<input
|
||||
type="time"
|
||||
value={slot.endTime}
|
||||
onChange={(e) => updateSlot(i, { endTime: e.target.value })}
|
||||
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 && (
|
||||
<span className="text-[11px] text-neutral-500 bg-neutral-200/50 rounded-full px-2 py-0.5 dark:bg-neutral-800/50">
|
||||
{dur}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSlot(i)}
|
||||
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSlot(i)}
|
||||
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
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} />
|
||||
Добавить дату
|
||||
@@ -176,93 +200,7 @@ function SlotsField({
|
||||
);
|
||||
}
|
||||
|
||||
// --- Image Upload ---
|
||||
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>
|
||||
);
|
||||
}
|
||||
// PhotoPreview replaced by shared ImageCropField
|
||||
|
||||
// --- Instagram Link Field ---
|
||||
function InstagramLinkField({
|
||||
@@ -285,8 +223,8 @@ function InstagramLinkField({
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
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 ${
|
||||
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||
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-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
{value && !error && (
|
||||
@@ -341,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 ---
|
||||
export default function MasterClassesEditorPage() {
|
||||
const [trainers, setTrainers] = useState<string[]>([]);
|
||||
const [styles, setStyles] = useState<string[]>([]);
|
||||
const [locations, setLocations] = useState<{ name: string; address: string }[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [dateFilter, setDateFilter] = useState<DateFilter>("all");
|
||||
const [locationFilter, setLocationFilter] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch trainers from team
|
||||
@@ -377,135 +417,185 @@ export default function MasterClassesEditorPage() {
|
||||
<SectionEditor<MasterClassesData>
|
||||
sectionKey="masterClasses"
|
||||
title="Мастер-классы"
|
||||
defaultData={{ items: [] }}
|
||||
>
|
||||
{(data, update) => (
|
||||
<>
|
||||
<InputField
|
||||
label="Заголовок секции"
|
||||
value={data.title}
|
||||
onChange={(v) => update({ ...data, title: v })}
|
||||
/>
|
||||
{(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;
|
||||
});
|
||||
|
||||
<InputField
|
||||
label="Текст после записи (success popup)"
|
||||
value={data.successMessage || ""}
|
||||
onChange={(v) => update({ ...data, successMessage: v || undefined })}
|
||||
placeholder="Вы записаны! Мы свяжемся с вами"
|
||||
/>
|
||||
const hiddenItems = new Set<number>();
|
||||
displayItems.forEach((item, i) => {
|
||||
if (
|
||||
!itemMatchesSearch(item, search) ||
|
||||
!itemMatchesDateFilter(item, dateFilter) ||
|
||||
!itemMatchesLocation(item, locationFilter)
|
||||
) {
|
||||
hiddenItems.add(i);
|
||||
}
|
||||
});
|
||||
|
||||
<TextareaField
|
||||
label="Текст для листа ожидания"
|
||||
value={data.waitingListText || ""}
|
||||
onChange={(v) => update({ ...data, waitingListText: v || undefined })}
|
||||
placeholder="Все места заняты, но мы добавили вас в лист ожидания..."
|
||||
rows={2}
|
||||
/>
|
||||
const visibleCount = data.items.length - hiddenItems.size;
|
||||
|
||||
<ArrayEditor
|
||||
label="Мастер-классы"
|
||||
items={data.items}
|
||||
onChange={(items) => update({ ...data, items })}
|
||||
renderItem={(item, _i, updateItem) => (
|
||||
<div className="space-y-3">
|
||||
<ValidationHint
|
||||
fields={{
|
||||
Название: item.title,
|
||||
Тренер: item.trainer,
|
||||
Стиль: item.style,
|
||||
Стоимость: item.cost,
|
||||
"Даты и время": (item.slots ?? []).length > 0 ? "ok" : "",
|
||||
}}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<InputField
|
||||
label="Заголовок секции"
|
||||
value={data.title}
|
||||
onChange={(v) => update({ ...data, title: v })}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Название"
|
||||
value={item.title}
|
||||
onChange={(v) => updateItem({ ...item, title: v })}
|
||||
placeholder="Мастер-класс от Анны Тарыбы"
|
||||
/>
|
||||
<FilterBar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
dateFilter={dateFilter}
|
||||
onDateFilterChange={setDateFilter}
|
||||
locationFilter={locationFilter}
|
||||
onLocationFilterChange={setLocationFilter}
|
||||
locations={locations}
|
||||
totalCount={data.items.length}
|
||||
visibleCount={visibleCount}
|
||||
/>
|
||||
|
||||
<ImageUploadField
|
||||
value={item.image}
|
||||
onChange={(v) => updateItem({ ...item, image: v })}
|
||||
/>
|
||||
<ArrayEditor
|
||||
label="Мастер-классы"
|
||||
items={displayItems}
|
||||
onChange={(items) => update({ ...data, items })}
|
||||
collapsible
|
||||
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" : ""}`}>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<AutocompleteMulti
|
||||
label="Тренер"
|
||||
value={item.trainer}
|
||||
onChange={(v) => updateItem({ ...item, trainer: v })}
|
||||
options={trainers}
|
||||
placeholder="Добавить тренера..."
|
||||
/>
|
||||
<AutocompleteMulti
|
||||
label="Стиль"
|
||||
value={item.style}
|
||||
onChange={(v) => updateItem({ ...item, style: v })}
|
||||
options={styles}
|
||||
placeholder="Добавить стиль..."
|
||||
/>
|
||||
</div>
|
||||
<ValidationHint
|
||||
fields={{
|
||||
Название: item.title,
|
||||
Тренер: item.trainer,
|
||||
Стиль: item.style,
|
||||
Стоимость: item.cost,
|
||||
"Даты и время": (item.slots ?? []).length > 0 ? "ok" : "",
|
||||
}}
|
||||
/>
|
||||
|
||||
<PriceField
|
||||
label="Стоимость"
|
||||
value={item.cost}
|
||||
onChange={(v) => updateItem({ ...item, cost: v })}
|
||||
placeholder="40"
|
||||
/>
|
||||
<InputField
|
||||
label="Название"
|
||||
value={item.title}
|
||||
onChange={(v) => updateItem({ ...item, title: v })}
|
||||
placeholder="Мастер-класс от Анны Тарыбы"
|
||||
/>
|
||||
|
||||
{locations.length > 0 && (
|
||||
<LocationSelect
|
||||
value={item.location || ""}
|
||||
onChange={(v) =>
|
||||
updateItem({ ...item, location: v || undefined })
|
||||
}
|
||||
locations={locations}
|
||||
/>
|
||||
)}
|
||||
{/* Photo + key fields side by side */}
|
||||
<div className="flex gap-5 items-center">
|
||||
<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">
|
||||
<AutocompleteMulti
|
||||
label="Тренер"
|
||||
value={item.trainer}
|
||||
onChange={(v) => updateItem({ ...item, trainer: v })}
|
||||
options={trainers}
|
||||
placeholder="Добавить тренера..."
|
||||
/>
|
||||
<AutocompleteMulti
|
||||
label="Стиль"
|
||||
value={item.style}
|
||||
onChange={(v) => updateItem({ ...item, style: v })}
|
||||
options={styles}
|
||||
placeholder="Добавить стиль..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<PriceField
|
||||
label="Стоимость"
|
||||
value={item.cost}
|
||||
onChange={(v) => updateItem({ ...item, cost: v })}
|
||||
placeholder="40"
|
||||
/>
|
||||
{locations.length > 0 && (
|
||||
<LocationSelect
|
||||
value={item.location || ""}
|
||||
onChange={(v) =>
|
||||
updateItem({ ...item, location: v || undefined })
|
||||
}
|
||||
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
|
||||
slots={item.slots ?? []}
|
||||
onChange={(slots) => updateItem({ ...item, slots })}
|
||||
/>
|
||||
<SlotsField
|
||||
slots={item.slots ?? []}
|
||||
onChange={(slots) => updateItem({ ...item, slots })}
|
||||
/>
|
||||
|
||||
<TextareaField
|
||||
label="Описание"
|
||||
value={item.description || ""}
|
||||
onChange={(v) =>
|
||||
updateItem({ ...item, description: v || undefined })
|
||||
}
|
||||
placeholder="Описание мастер-класса, трек, стиль..."
|
||||
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>
|
||||
)}
|
||||
createItem={() => ({
|
||||
title: "",
|
||||
image: "",
|
||||
slots: [],
|
||||
trainer: "",
|
||||
cost: "",
|
||||
style: "",
|
||||
})}
|
||||
addLabel="Добавить мастер-класс"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<RichTextarea
|
||||
label="Описание"
|
||||
value={item.description || ""}
|
||||
onChange={(v) =>
|
||||
updateItem({ ...item, description: v || undefined })
|
||||
}
|
||||
placeholder="Описание мастер-класса, трек, стиль..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
createItem={() => ({
|
||||
title: "",
|
||||
image: "",
|
||||
slots: [],
|
||||
trainer: "",
|
||||
cost: "",
|
||||
style: "",
|
||||
})}
|
||||
addLabel="Добавить мастер-класс"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</SectionEditor>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, TextareaField } from "../_components/FormField";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
import { Upload, Loader2, ImageIcon, X } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { ImageCropField } from "../_components/ImageCropField";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import type { NewsItem } from "@/types/content";
|
||||
|
||||
interface NewsData {
|
||||
@@ -13,96 +12,9 @@ interface NewsData {
|
||||
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() {
|
||||
return (
|
||||
<SectionEditor<NewsData> sectionKey="news" title="Новости">
|
||||
<SectionEditor<NewsData> sectionKey="news" title="Новости" defaultData={{ items: [] }}>
|
||||
{(data, update) => (
|
||||
<>
|
||||
<InputField
|
||||
@@ -115,49 +27,82 @@ export default function NewsEditorPage() {
|
||||
label="Новости"
|
||||
items={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="grid gap-3 sm:grid-cols-2">
|
||||
<InputField
|
||||
label="Заголовок"
|
||||
value={item.title}
|
||||
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]"
|
||||
/>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
<InputField
|
||||
label="Заголовок"
|
||||
value={item.title}
|
||||
onChange={(v) => updateItem({ ...item, title: v })}
|
||||
/>
|
||||
<TextareaField
|
||||
label="Текст"
|
||||
value={item.text}
|
||||
onChange={(v) => updateItem({ ...item, text: v })}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<ImageUploadField
|
||||
value={item.image || ""}
|
||||
onChange={(v) => updateItem({ ...item, image: v || undefined })}
|
||||
/>
|
||||
<InputField
|
||||
label="Ссылка (необязательно)"
|
||||
value={item.link || ""}
|
||||
onChange={(v) => updateItem({ ...item, link: v || undefined })}
|
||||
placeholder="https://instagram.com/p/..."
|
||||
/>
|
||||
</div>
|
||||
<ImageCropField
|
||||
image={item.image || ""}
|
||||
focalX={item.imageFocalX ?? 50}
|
||||
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
|
||||
label="Ссылка (необязательно)"
|
||||
value={item.link || ""}
|
||||
onChange={(v) => updateItem({ ...item, link: v || undefined })}
|
||||
placeholder="https://instagram.com/p/..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
createItem={(): NewsItem => ({
|
||||
title: "",
|
||||
text: "",
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
date: new Date().toISOString(),
|
||||
})}
|
||||
addLabel="Добавить новость"
|
||||
addPosition="top"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw, Sparkles,
|
||||
} from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { ParticipantLimits, SelectField } from "../_components/FormField";
|
||||
import { ParticipantLimits, SelectField, RichTextarea } from "../_components/FormField";
|
||||
import { PriceField } from "../_components/PriceField";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -19,8 +20,6 @@ interface OpenDayEvent {
|
||||
discountThreshold: number;
|
||||
minBookings: number;
|
||||
maxParticipants: number;
|
||||
successMessage?: string;
|
||||
waitingListText?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
@@ -64,7 +63,7 @@ function EventSettings({
|
||||
onChange: (patch: Partial<OpenDayEvent>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-4">
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-5 space-y-4 dark:border-white/10 dark:bg-neutral-900">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<Calendar size={18} className="text-gold" />
|
||||
Настройки мероприятия
|
||||
@@ -72,66 +71,52 @@ function EventSettings({
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Название</label>
|
||||
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
value={event.title}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
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"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label>
|
||||
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">Дата</label>
|
||||
<input
|
||||
type="date"
|
||||
value={event.date}
|
||||
onChange={(e) => onChange({ 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]"
|
||||
onChange={(e) => {
|
||||
const newDate = e.target.value;
|
||||
const isPast = newDate && newDate < new Date().toISOString().slice(0, 10);
|
||||
onChange({ date: newDate, ...(isPast || !newDate ? { active: false } : {}) });
|
||||
}}
|
||||
className={`w-full rounded-lg border bg-neutral-100 px-4 py-2.5 text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
|
||||
event.date && event.date < new Date().toISOString().slice(0, 10)
|
||||
? "border-amber-500/50"
|
||||
: "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
{!event.date && (
|
||||
<p className="mt-1 text-[11px] text-amber-400">Укажите дату для публикации</p>
|
||||
)}
|
||||
{event.date && event.date < new Date().toISOString().slice(0, 10) && (
|
||||
<p className="mt-1 text-[11px] text-amber-400">Дата в прошлом — переведено в черновик</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Описание</label>
|
||||
<textarea
|
||||
value={event.description || ""}
|
||||
onChange={(e) => onChange({ description: e.target.value || undefined })}
|
||||
rows={2}
|
||||
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"
|
||||
placeholder="Описание мероприятия..."
|
||||
/>
|
||||
</div>
|
||||
<RichTextarea
|
||||
label="Описание"
|
||||
value={event.description || ""}
|
||||
onChange={(v) => onChange({ description: v || undefined })}
|
||||
rows={3}
|
||||
placeholder="Описание мероприятия..."
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Текст после записи</label>
|
||||
<textarea
|
||||
value={event.successMessage || ""}
|
||||
onChange={(e) => onChange({ successMessage: e.target.value || undefined })}
|
||||
rows={2}
|
||||
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"
|
||||
placeholder="Вы записаны!"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Текст для листа ожидания</label>
|
||||
<textarea
|
||||
value={event.waitingListText || ""}
|
||||
onChange={(e) => onChange({ waitingListText: e.target.value || undefined })}
|
||||
rows={2}
|
||||
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"
|
||||
placeholder="Все места заняты, но мы добавили вас в лист ожидания..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={event.pricePerClass}
|
||||
onChange={(e) => onChange({ pricePerClass: parseInt(e.target.value) || 0 })}
|
||||
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 sm:max-w-xs"
|
||||
<div className="sm:max-w-xs">
|
||||
<PriceField
|
||||
label="Цена за занятие"
|
||||
value={event.pricePerClass ? `${event.pricePerClass} BYN` : ""}
|
||||
onChange={(v) => onChange({ pricePerClass: parseInt(v) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +131,7 @@ function EventSettings({
|
||||
className={`flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
|
||||
event.discountPrice > 0
|
||||
? "bg-gold/15 text-gold border border-gold/30"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
@@ -155,21 +140,19 @@ function EventSettings({
|
||||
{event.discountPrice > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 mt-3">
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Цена со скидкой (BYN)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={event.discountPrice}
|
||||
onChange={(e) => onChange({ discountPrice: parseInt(e.target.value) || 0 })}
|
||||
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"
|
||||
<PriceField
|
||||
label="Цена со скидкой"
|
||||
value={event.discountPrice ? `${event.discountPrice} BYN` : ""}
|
||||
onChange={(v) => onChange({ discountPrice: parseInt(v) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">От N занятий</label>
|
||||
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">От N занятий</label>
|
||||
<input
|
||||
type="number"
|
||||
value={event.discountThreshold}
|
||||
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 1 })}
|
||||
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"
|
||||
value={event.discountThreshold || ""}
|
||||
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 0 })}
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,16 +169,28 @@ function EventSettings({
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ active: !event.active })}
|
||||
onClick={() => {
|
||||
const isPast = !event.date || event.date < new Date().toISOString().slice(0, 10);
|
||||
if (!event.active && isPast) return;
|
||||
onChange({ active: !event.active });
|
||||
}}
|
||||
className={`relative flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
|
||||
event.active
|
||||
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10"
|
||||
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 cursor-pointer"
|
||||
: !event.date || event.date < new Date().toISOString().slice(0, 10)
|
||||
? "bg-neutral-100 text-neutral-400 border border-neutral-200 opacity-50 cursor-not-allowed dark:bg-neutral-800 dark:text-neutral-500 dark:border-white/5"
|
||||
: "bg-neutral-100 text-neutral-500 border border-neutral-200 cursor-pointer hover:border-gold/40 hover:text-gold hover:bg-gold/5 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10"
|
||||
}`}
|
||||
>
|
||||
{event.active ? <CheckCircle2 size={14} /> : <Ban size={14} />}
|
||||
{event.active ? "Опубликовано" : "Черновик"}
|
||||
{event.active ? (
|
||||
<><CheckCircle2 size={14} /> Опубликовано</>
|
||||
) : (
|
||||
<><Ban size={14} /> Черновик</>
|
||||
)}
|
||||
</button>
|
||||
{!event.active && (!event.date || event.date < new Date().toISOString().slice(0, 10)) && (
|
||||
<span className="text-[11px] text-amber-400">Укажите будущую дату для публикации</span>
|
||||
)}
|
||||
<span className="text-xs text-neutral-500">
|
||||
{event.pricePerClass} BYN / занятие{event.discountPrice > 0 && event.discountThreshold > 0 && `, от ${event.discountThreshold} — ${event.discountPrice} BYN`}
|
||||
</span>
|
||||
@@ -246,10 +241,10 @@ function NewClassForm({
|
||||
<div ref={formRef} className="p-2 space-y-1.5 ring-1 ring-gold/30 rounded-lg">
|
||||
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
|
||||
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button onClick={onCancel} className="text-[10px] text-neutral-500 hover:text-white px-1">Отмена</button>
|
||||
<div className="flex gap-2 justify-end mt-2">
|
||||
<button onClick={onCancel} className="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">Отмена</button>
|
||||
<button onClick={() => canSave && onSave({ trainer, style, endTime })} disabled={!canSave}
|
||||
className="text-[10px] text-gold hover:text-gold-light px-1 font-medium disabled:opacity-30 disabled:cursor-not-allowed">OK</button>
|
||||
className="rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -262,6 +257,8 @@ function ClassCell({
|
||||
minBookings,
|
||||
trainers,
|
||||
styles,
|
||||
editing,
|
||||
onEdit,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onCancel,
|
||||
@@ -270,11 +267,12 @@ function ClassCell({
|
||||
minBookings: number;
|
||||
trainers: string[];
|
||||
styles: string[];
|
||||
editing: boolean;
|
||||
onEdit: (id: number | null) => void;
|
||||
onUpdate: (id: number, data: Partial<OpenDayClass>) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onCancel: (id: number) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [trainer, setTrainer] = useState(cls.trainer);
|
||||
const [style, setStyle] = useState(cls.style);
|
||||
|
||||
@@ -283,7 +281,7 @@ function ClassCell({
|
||||
function save() {
|
||||
if (trainer.trim() && style.trim()) {
|
||||
onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim() });
|
||||
setEditing(false);
|
||||
onEdit(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,12 +290,12 @@ function ClassCell({
|
||||
<div className="p-2 space-y-1.5 rounded-lg">
|
||||
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
|
||||
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1">
|
||||
<div className="flex gap-2 justify-end mt-2">
|
||||
<button onClick={() => onEdit(null)} className="rounded-md border border-white/10 px-3 py-1 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
|
||||
Отмена
|
||||
</button>
|
||||
<button onClick={save} className="text-[10px] text-gold hover:text-gold-light px-1 font-medium">
|
||||
OK
|
||||
<button onClick={save} className="rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,15 +306,15 @@ function ClassCell({
|
||||
<div
|
||||
className={`group relative p-2 rounded-lg cursor-pointer transition-all ${
|
||||
cls.cancelled
|
||||
? "bg-neutral-800/30 opacity-50"
|
||||
? "bg-neutral-200/50 opacity-50 dark:bg-neutral-800/30"
|
||||
: atRisk
|
||||
? "bg-red-500/5 border border-red-500/20"
|
||||
: "bg-gold/5 border border-gold/15 hover:border-gold/30"
|
||||
: "bg-gold/10 border border-gold/25 dark:bg-gold/5 dark:border-gold/15 hover:border-gold/30"
|
||||
}`}
|
||||
onClick={() => setEditing(true)}
|
||||
onClick={() => onEdit(cls.id)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-white truncate">{cls.style}</span>
|
||||
<span className="text-xs font-medium text-neutral-900 truncate dark:text-white">{cls.style}</span>
|
||||
<span className="text-[10px] text-neutral-500">{cls.startTime}–{cls.endTime}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-neutral-400 truncate">{cls.trainer}</div>
|
||||
@@ -335,21 +333,21 @@ function ClassCell({
|
||||
)}
|
||||
{cls.cancelled && <span className="text-[9px] text-neutral-500">отменено</span>}
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="absolute top-1 right-1 hidden group-hover:flex gap-0.5">
|
||||
{/* Actions — always visible on mobile, hover on desktop */}
|
||||
<div className="absolute top-1.5 right-1.5 flex gap-1 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }}
|
||||
className={`rounded p-0.5 ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400" : "text-neutral-500 hover:text-yellow-400"}`}
|
||||
className={`rounded-md p-1 transition-colors ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400 hover:bg-emerald-400/10" : "text-neutral-500 hover:text-yellow-400 hover:bg-yellow-400/10"}`}
|
||||
title={cls.cancelled ? "Восстановить" : "Отменить"}
|
||||
>
|
||||
{cls.cancelled ? <RotateCcw size={10} /> : <Ban size={10} />}
|
||||
{cls.cancelled ? <RotateCcw size={14} /> : <Ban size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(cls.id); }}
|
||||
className="rounded p-0.5 text-neutral-500 hover:text-red-400"
|
||||
className="rounded-md p-1 text-neutral-500 hover:text-red-400 hover:bg-red-400/10 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,8 +374,23 @@ function ScheduleGrid({
|
||||
onClassesChange: () => void;
|
||||
}) {
|
||||
const [selectedHall, setSelectedHall] = useState(halls[0] ?? "");
|
||||
const [editingClassId, setEditingClassId] = useState<number | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<{ message: string; onConfirm: () => void } | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const timeSlots = generateTimeSlots(10, 22);
|
||||
|
||||
// Close edit mode when clicking outside the grid
|
||||
useEffect(() => {
|
||||
if (editingClassId === null) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (gridRef.current && !gridRef.current.contains(e.target as Node)) {
|
||||
setEditingClassId(null);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [editingClassId]);
|
||||
|
||||
// Build lookup: time -> class for selected hall
|
||||
const hallClasses = useMemo(() => {
|
||||
const map: Record<string, OpenDayClass> = {};
|
||||
@@ -398,37 +411,64 @@ function ScheduleGrid({
|
||||
const [creatingTime, setCreatingTime] = useState<string | null>(null);
|
||||
|
||||
async function confirmCreate(startTime: string, data: { trainer: string; style: string; endTime: string }) {
|
||||
await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
|
||||
});
|
||||
setCreatingTime(null);
|
||||
onClassesChange();
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
setCreatingTime(null);
|
||||
onClassesChange();
|
||||
} catch {
|
||||
alert("Не удалось создать занятие");
|
||||
}
|
||||
}
|
||||
|
||||
async function updateClass(id: number, data: Partial<OpenDayClass>) {
|
||||
await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ...data }),
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ...data }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
onClassesChange();
|
||||
} catch {
|
||||
alert("Не удалось обновить занятие");
|
||||
}
|
||||
}
|
||||
|
||||
function deleteClass(id: number) {
|
||||
setConfirmAction({
|
||||
message: "Удалить занятие? Это действие нельзя отменить.",
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const res = await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error();
|
||||
onClassesChange();
|
||||
} catch {
|
||||
alert("Не удалось удалить занятие");
|
||||
}
|
||||
},
|
||||
});
|
||||
onClassesChange();
|
||||
}
|
||||
|
||||
async function deleteClass(id: number) {
|
||||
await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
|
||||
onClassesChange();
|
||||
}
|
||||
|
||||
async function cancelClass(id: number) {
|
||||
function cancelClass(id: number) {
|
||||
const cls = classes.find((c) => c.id === id);
|
||||
if (!cls) return;
|
||||
await updateClass(id, { cancelled: !cls.cancelled });
|
||||
setConfirmAction({
|
||||
message: cls.cancelled
|
||||
? "Восстановить занятие?"
|
||||
: `Отменить занятие? (${cls.bookingCount} записей)`,
|
||||
onConfirm: async () => {
|
||||
await updateClass(id, { cancelled: !cls.cancelled });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-3">
|
||||
<div ref={gridRef} className="rounded-xl border border-neutral-200 bg-white p-5 space-y-3 dark:border-white/10 dark:bg-neutral-900">
|
||||
<h2 className="text-lg font-bold">Расписание</h2>
|
||||
|
||||
{halls.length === 0 ? (
|
||||
@@ -444,7 +484,7 @@ function ScheduleGrid({
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
selectedHall === hall
|
||||
? "bg-gold/20 text-gold border border-gold/40"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{hall}
|
||||
@@ -462,8 +502,8 @@ function ScheduleGrid({
|
||||
{timeSlots.map((time) => {
|
||||
const cls = hallClasses[time];
|
||||
return (
|
||||
<div key={time} className="flex items-start gap-3 border-t border-white/5 py-1.5">
|
||||
<span className="text-xs text-neutral-500 w-12 pt-1.5 shrink-0">{time}</span>
|
||||
<div key={time} className="flex items-start gap-3 border-t border-neutral-200 py-1.5 dark:border-white/5">
|
||||
<span className="text-xs text-neutral-400 w-12 pt-1.5 shrink-0 dark:text-neutral-500">{time}</span>
|
||||
<div className="flex-1">
|
||||
{cls ? (
|
||||
<ClassCell
|
||||
@@ -471,6 +511,8 @@ function ScheduleGrid({
|
||||
minBookings={minBookings}
|
||||
trainers={trainers}
|
||||
styles={styles}
|
||||
editing={editingClassId === cls.id}
|
||||
onEdit={(id) => { setEditingClassId(id); if (id) setCreatingTime(null); }}
|
||||
onUpdate={updateClass}
|
||||
onDelete={deleteClass}
|
||||
onCancel={cancelClass}
|
||||
@@ -485,8 +527,8 @@ function ScheduleGrid({
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setCreatingTime(time)}
|
||||
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors"
|
||||
onClick={() => { setCreatingTime(time); setEditingClassId(null); }}
|
||||
className="w-full rounded-lg border border-dashed border-neutral-200 p-2 text-neutral-400 hover:text-gold hover:border-gold/20 transition-colors dark:border-white/5 dark:text-neutral-600"
|
||||
>
|
||||
<Plus size={12} className="mx-auto" />
|
||||
</button>
|
||||
@@ -498,6 +540,30 @@ function ScheduleGrid({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{confirmAction && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirmAction(null)}>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div className="relative w-full max-w-xs rounded-xl border border-neutral-200 bg-white p-5 shadow-2xl dark:border-white/[0.08] dark:bg-[#141414]" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="text-sm text-neutral-900 text-center dark:text-white">{confirmAction.message}</p>
|
||||
<div className="mt-4 flex gap-2 justify-center">
|
||||
<button
|
||||
onClick={() => setConfirmAction(null)}
|
||||
className="rounded-lg border border-neutral-200 px-4 py-2 text-xs font-medium 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"
|
||||
>
|
||||
Нет
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { confirmAction.onConfirm(); setConfirmAction(null); }}
|
||||
className="rounded-lg bg-gold/20 border border-gold/30 px-4 py-2 text-xs font-medium text-gold hover:bg-gold/30 transition-colors"
|
||||
>
|
||||
Да
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -514,7 +580,8 @@ export default function OpenDayAdminPage() {
|
||||
const [trainers, setTrainers] = useState<string[]>([]);
|
||||
const [styles, setStyles] = useState<string[]>([]);
|
||||
const [halls, setHalls] = useState<string[]>([]);
|
||||
const saveTimerRef = { current: null as ReturnType<typeof setTimeout> | null };
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingSaveRef = useRef(false);
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
@@ -545,8 +612,11 @@ export default function OpenDayAdminPage() {
|
||||
}
|
||||
|
||||
// Auto-save event changes
|
||||
const eventRef = useRef<OpenDayEvent | null>(null);
|
||||
const saveEvent = useCallback(
|
||||
(updated: OpenDayEvent) => {
|
||||
eventRef.current = updated;
|
||||
pendingSaveRef.current = true;
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
setSaving(true);
|
||||
@@ -557,6 +627,7 @@ export default function OpenDayAdminPage() {
|
||||
body: JSON.stringify(updated),
|
||||
});
|
||||
setSaveStatus(res.ok ? "saved" : "error");
|
||||
if (res.ok) pendingSaveRef.current = false;
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
@@ -567,31 +638,66 @@ export default function OpenDayAdminPage() {
|
||||
[]
|
||||
);
|
||||
|
||||
// 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;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Force save then navigate
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
const data = eventRef.current;
|
||||
if (data) {
|
||||
adminFetch("/api/admin/open-day", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}).finally(() => { window.location.href = href; });
|
||||
} else {
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
document.addEventListener("click", onLinkClick, true);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
document.removeEventListener("click", onLinkClick, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleEventChange(patch: Partial<OpenDayEvent>) {
|
||||
if (!event) return;
|
||||
const updated = { ...event, ...patch };
|
||||
setEvent(updated);
|
||||
// Skip auto-save only if date is partially typed (prevents 400 errors)
|
||||
if (updated.date && updated.date.length > 0 && updated.date.length < 10) return;
|
||||
saveEvent(updated);
|
||||
}
|
||||
|
||||
async function createEvent() {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const res = await adminFetch("/api/admin/open-day", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ date: today }),
|
||||
body: JSON.stringify({ date: "" }),
|
||||
});
|
||||
const { id } = await res.json();
|
||||
setEvent({
|
||||
id,
|
||||
date: today,
|
||||
title: "День открытых дверей",
|
||||
pricePerClass: 30,
|
||||
discountPrice: 20,
|
||||
discountThreshold: 3,
|
||||
minBookings: 4,
|
||||
date: "",
|
||||
title: "",
|
||||
pricePerClass: 0,
|
||||
discountPrice: 0,
|
||||
discountThreshold: 0,
|
||||
minBookings: 0,
|
||||
maxParticipants: 0,
|
||||
active: true,
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||