feat: move amenities directory to RoomCategoriesPage + cdn.hotelsync.ru for uploads

- Remove "Справочник удобств" block from RoomsPage, add to RoomCategoriesPage
- Fix RoomsPage imports (add Pencil back, remove unused amenity icons)
- Update hint in CategoryForm to point to current page
- Upload route now returns cdn.hotelsync.ru URLs instead of api.hotelsync.ru/uploads/
- Nginx config for cdn.hotelsync.ru (serves /opt/hotelsync/uploads directly)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 12:31:08 +03:00
parent 3ef7f8265a
commit 3ed3156fe8
4 changed files with 445 additions and 101 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { Plus, Pencil, Trash2, ImagePlus, X as XIcon, Tag, ChevronRight, Loader2 } from 'lucide-react'
import { Plus, Pencil, Trash2, ImagePlus, X as XIcon, Tag, ChevronRight, ChevronDown, ChevronUp, Check, Loader2 } from 'lucide-react'
import { cn } from '../lib/utils'
import type { RoomCategory } from '../types'
import { useAmenities } from '../contexts/AmenitiesContext'
@@ -184,7 +184,7 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">Удобства в номере</label>
<span className="text-xs text-slate-400">Справочник удобств на странице «Номера»</span>
<span className="text-xs text-slate-400">Справочник удобств ниже на этой странице</span>
</div>
<div className="flex flex-wrap gap-2">
{allAmenities.map(a => {
@@ -308,6 +308,13 @@ export function RoomCategoriesPage() {
const [editingCat, setEditingCat] = useState<RoomCategory | undefined>(undefined)
const [formOpen, setFormOpen] = useState(false)
// Amenities directory state
const { amenities, addAmenity, removeAmenity, renameAmenity } = useAmenities()
const [showAmenities, setShowAmenities] = useState(false)
const [newAmenity, setNewAmenity] = useState('')
const [editingAmenity, setEditingAmenity] = useState<string | null>(null)
const [editAmenityValue, setEditAmenityValue] = useState('')
useEffect(() => {
if (!slug) return
api.categories.list(slug)
@@ -457,6 +464,94 @@ export function RoomCategoriesPage() {
)}
</div>
{/* Справочник удобств */}
<div className="card overflow-hidden">
<button
onClick={() => setShowAmenities(v => !v)}
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Справочник удобств</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
{amenities.length} удобств · используется в номерах и категориях
</p>
</div>
{showAmenities
? <ChevronUp size={16} className="text-slate-400" />
: <ChevronDown size={16} className="text-slate-400" />}
</button>
{showAmenities && (
<div className="border-t border-slate-200 dark:border-slate-700 p-5 space-y-4">
<div className="flex gap-2">
<input
type="text"
className="input flex-1 text-sm"
placeholder="Добавить удобство (напр. Фен, Сейф, Балкон...)"
value={newAmenity}
onChange={e => setNewAmenity(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { e.preventDefault(); if (newAmenity.trim()) { addAmenity(newAmenity.trim()); setNewAmenity('') } }
}}
/>
<button
onClick={() => { if (newAmenity.trim()) { addAmenity(newAmenity.trim()); setNewAmenity('') } }}
disabled={!newAmenity.trim() || amenities.includes(newAmenity.trim())}
className="btn-primary px-4 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={15} />
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{amenities.map(a => (
<div
key={a}
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 group"
>
{editingAmenity === a ? (
<>
<input
autoFocus
className="input text-xs flex-1 py-0.5 h-6"
value={editAmenityValue}
onChange={e => setEditAmenityValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { renameAmenity(a, editAmenityValue); setEditingAmenity(null) }
if (e.key === 'Escape') setEditingAmenity(null)
}}
/>
<button onClick={() => { renameAmenity(a, editAmenityValue); setEditingAmenity(null) }}
className="text-emerald-500 hover:text-emerald-600 shrink-0"><Check size={13} /></button>
<button onClick={() => setEditingAmenity(null)}
className="text-slate-400 hover:text-slate-600 shrink-0"><XIcon size={13} /></button>
</>
) : (
<>
<span className="text-xs text-slate-700 dark:text-slate-300 flex-1 truncate">{a}</span>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={() => { setEditingAmenity(a); setEditAmenityValue(a) }}
className="p-0.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700"
>
<Pencil size={11} />
</button>
<button
onClick={() => removeAmenity(a)}
className="p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-500"
>
<Trash2 size={11} />
</button>
</div>
</>
)}
</div>
))}
</div>
</div>
)}
</div>
{formOpen && (
<CategoryForm
category={editingCat}

View File

@@ -1,6 +1,5 @@
import { useState, useEffect } from 'react'
import { BedDouble, Users, Wifi, Plus, Search, Pencil, ChevronDown, ChevronUp, Check, Trash2, X as XIcon, Loader2 } from 'lucide-react'
import { useAmenities } from '../contexts/AmenitiesContext'
import { BedDouble, Users, Wifi, Plus, Search, Loader2, Pencil } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { api } from '../lib/api'
import type { Room, RoomCategory } from '../types'
@@ -20,11 +19,6 @@ export function RoomsPage() {
const [modalOpen, setModalOpen] = useState(false)
const [editingRoom, setEditingRoom] = useState<Room | undefined>(undefined)
const { amenities, addAmenity, removeAmenity, renameAmenity } = useAmenities()
const [showAmenities, setShowAmenities] = useState(false)
const [newAmenity, setNewAmenity] = useState('')
const [editingAmenity, setEditingAmenity] = useState<string | null>(null)
const [editAmenityValue, setEditAmenityValue] = useState('')
useEffect(() => {
if (!slug) return
@@ -209,94 +203,6 @@ export function RoomsPage() {
</div>
)}
{/* Справочник удобств */}
<div className="card overflow-hidden">
<button
onClick={() => setShowAmenities(v => !v)}
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Справочник удобств</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
{amenities.length} удобств · используется в номерах и категориях
</p>
</div>
{showAmenities
? <ChevronUp size={16} className="text-slate-400" />
: <ChevronDown size={16} className="text-slate-400" />}
</button>
{showAmenities && (
<div className="border-t border-slate-200 dark:border-slate-700 p-5 space-y-4">
<div className="flex gap-2">
<input
type="text"
className="input flex-1 text-sm"
placeholder="Добавить удобство (напр. Фен, Сейф, Балкон...)"
value={newAmenity}
onChange={e => setNewAmenity(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { e.preventDefault(); if (newAmenity.trim()) { addAmenity(newAmenity.trim()); setNewAmenity('') } }
}}
/>
<button
onClick={() => { if (newAmenity.trim()) { addAmenity(newAmenity.trim()); setNewAmenity('') } }}
disabled={!newAmenity.trim() || amenities.includes(newAmenity.trim())}
className="btn-primary px-4 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={15} />
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{amenities.map(a => (
<div
key={a}
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 group"
>
{editingAmenity === a ? (
<>
<input
autoFocus
className="input text-xs flex-1 py-0.5 h-6"
value={editAmenityValue}
onChange={e => setEditAmenityValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { renameAmenity(a, editAmenityValue); setEditingAmenity(null) }
if (e.key === 'Escape') setEditingAmenity(null)
}}
/>
<button onClick={() => { renameAmenity(a, editAmenityValue); setEditingAmenity(null) }}
className="text-emerald-500 hover:text-emerald-600 shrink-0"><Check size={13} /></button>
<button onClick={() => setEditingAmenity(null)}
className="text-slate-400 hover:text-slate-600 shrink-0"><XIcon size={13} /></button>
</>
) : (
<>
<span className="text-xs text-slate-700 dark:text-slate-300 flex-1 truncate">{a}</span>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={() => { setEditingAmenity(a); setEditAmenityValue(a) }}
className="p-0.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700"
>
<Pencil size={11} />
</button>
<button
onClick={() => removeAmenity(a)}
className="p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-500"
>
<Trash2 size={11} />
</button>
</div>
</>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Modal */}
<RoomModal
open={modalOpen}