Эх сурвалжийг харах

refactor: извлечь общий компонент Modal, скроллбар внутри контента

- Создан переиспользуемый Modal (max-h-[90vh] overflow-y-auto на inner div)
- EditModal: исправлен скроллбар — был на оверлее, теперь внутри модалки
- LoginModal, RegisterModal, ReworkModal, CreateUserModal: добавлен max-h + overflow
- AddLocationModal, подтверждения удаления: переведены на общий Modal
- Obsidian: atomic-modal-scrollbar-inside, decision-modal-component-extraction, MOC-frontend-patterns
neyrogovnarik 1 сар өмнө
parent
commit
6adbdc3fef

+ 7 - 6
frontend/src/app/admin/page.tsx

@@ -3,6 +3,7 @@
 import { useState, useEffect, useCallback } from 'react'
 import { api, ApiRequestError } from '@/lib/api'
 import type { Place } from '@/types'
+import Modal from '@/components/Modal'
 
 type FilterTab = 'pending_moderation' | 'published' | 'rejected' | 'revision' | 'deleted'
 
@@ -18,8 +19,8 @@ function ReworkModal({ place, onClose, onConfirm }: { place: Place; onClose: ()
   const [comment, setComment] = useState('')
 
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
-      <div className="w-full max-w-md rounded-xl bg-[#1e293b] p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
+    <Modal onClose={onClose} size="md">
+      <div className="p-6">
         <h2 className="mb-2 text-lg font-bold text-white">Отправить на доработку</h2>
         <p className="mb-4 text-sm text-white/60">{place.title}</p>
 
@@ -39,7 +40,7 @@ function ReworkModal({ place, onClose, onConfirm }: { place: Place; onClose: ()
           >Отправить</button>
         </div>
       </div>
-    </div>
+    </Modal>
   )
 }
 
@@ -204,8 +205,8 @@ export default function AdminModerationPage() {
       )}
 
       {hardDeletePlace && (
-        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setHardDeletePlace(null)}>
-          <div className="w-full max-w-sm rounded-xl bg-[#1e293b] p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
+        <Modal onClose={() => setHardDeletePlace(null)} size="sm">
+          <div className="p-6">
             <h3 className="mb-2 text-lg font-bold text-white">Полное удаление</h3>
             <p className="mb-2 text-sm text-white/60">{hardDeletePlace.title}</p>
             <p className="mb-4 text-sm text-red-400">Место и все его файлы будут безвозвратно удалены.</p>
@@ -218,7 +219,7 @@ export default function AdminModerationPage() {
               >Удалить навсегда</button>
             </div>
           </div>
-        </div>
+        </Modal>
       )}
     </div>
   )

+ 4 - 3
frontend/src/app/admin/users/page.tsx

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
 import { api } from '@/lib/api'
 import { useAuth } from '@/hooks/useAuth'
 import type { User } from '@/types'
+import Modal from '@/components/Modal'
 
 const roleLabels: Record<string, string> = {
   superadmin: 'Суперадмин',
@@ -47,8 +48,8 @@ function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreate
   }
 
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
-      <div className="w-full max-w-sm rounded-xl bg-[#1e293b] p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
+    <Modal onClose={onClose} size="sm">
+      <div className="p-6">
         <h2 className="mb-4 text-lg font-bold text-white">Создать пользователя</h2>
         {error && <p className="mb-3 text-sm text-red-400">{error}</p>}
         <form onSubmit={handleSubmit} className="space-y-3">
@@ -75,7 +76,7 @@ function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreate
           </div>
         </form>
       </div>
-    </div>
+    </Modal>
   )
 }
 

+ 18 - 19
frontend/src/app/places/my/page.tsx

@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from 'react'
 import { api } from '@/lib/api'
 import type { Place } from '@/types'
 import PlaceForm from '@/components/PlaceForm'
+import Modal from '@/components/Modal'
 
 const STATUS_LABELS: Record<string, { label: string; color: string }> = {
   published: { label: 'Опубликовано', color: 'text-green-400' },
@@ -25,23 +26,21 @@ function EditModal({ placeId, onClose, onSaved }: { placeId: string; onClose: ()
   }, [placeId, onClose])
 
   return (
-    <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 pt-10 pb-16 backdrop-blur-sm" onClick={onClose}>
-      <div className="w-full max-w-2xl rounded-xl bg-[#1e293b] shadow-xl" onClick={(e) => e.stopPropagation()}>
-        {loading ? (
-          <div className="flex items-center justify-center p-8">
-            <p className="text-white/40">Загрузка...</p>
-          </div>
-        ) : place ? (
-          <div className="p-6">
-            <div className="mb-4 flex items-center justify-between">
-              <h2 className="text-2xl font-bold text-white">Редактировать</h2>
-              <button onClick={onClose} className="text-3xl leading-none text-white/60 hover:text-white transition">&times;</button>
-            </div>
-            <PlaceForm type={place.type} place={place} onSuccess={() => { onClose(); onSaved() }} />
+    <Modal onClose={onClose} size="xl">
+      {loading ? (
+        <div className="flex items-center justify-center p-8">
+          <p className="text-white/40">Загрузка...</p>
+        </div>
+      ) : place ? (
+        <div className="p-6">
+          <div className="mb-4 flex items-center justify-between">
+            <h2 className="text-2xl font-bold text-white">Редактировать</h2>
+            <button onClick={onClose} className="text-3xl leading-none text-white/60 hover:text-white transition">&times;</button>
           </div>
-        ) : null}
-      </div>
-    </div>
+          <PlaceForm type={place.type} place={place} onSuccess={() => { onClose(); onSaved() }} />
+        </div>
+      ) : null}
+    </Modal>
   )
 }
 
@@ -96,8 +95,8 @@ function PlaceCard({ place, onEdit, onDeleted }: { place: Place; onEdit: () => v
       </div>
 
       {showConfirm && (
-        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setShowConfirm(false)}>
-          <div className="w-full max-w-sm rounded-xl bg-[#1e293b] p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
+        <Modal onClose={() => setShowConfirm(false)} size="sm">
+          <div className="p-6">
             <h3 className="mb-2 text-lg font-bold text-white">Удалить место?</h3>
             <p className="mb-4 text-sm text-white/60">{place.title}</p>
             <p className="mb-4 text-sm text-red-400">Это действие нельзя отменить.</p>
@@ -121,7 +120,7 @@ function PlaceCard({ place, onEdit, onDeleted }: { place: Place; onEdit: () => v
               >{deleting ? 'Удаление...' : 'Удалить'}</button>
             </div>
           </div>
-        </div>
+        </Modal>
       )}
     </div>
   )

+ 11 - 12
frontend/src/components/AddLocationModal.tsx

@@ -1,21 +1,20 @@
 'use client'
 
 import PlaceForm from './PlaceForm'
+import Modal from '@/components/Modal'
 
 export default function AddLocationModal({ onClose }: { onClose: () => void }) {
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
-      <div className="w-full max-w-2xl overflow-hidden rounded-2xl bg-[#1e293b]">
-        <div className="relative max-h-[90vh] overflow-y-auto">
-          <button
-            onClick={onClose}
-            className="absolute right-4 top-4 z-10 text-white/60 hover:text-white"
-          >
-            ✕
-          </button>
-          <PlaceForm type="place" onSuccess={onClose} />
-        </div>
+    <Modal onClose={onClose} size="xl" className="rounded-2xl">
+      <div className="relative">
+        <button
+          onClick={onClose}
+          className="absolute right-4 top-4 z-10 text-white/60 hover:text-white"
+        >
+          ✕
+        </button>
+        <PlaceForm type="place" onSuccess={onClose} />
       </div>
-    </div>
+    </Modal>
   )
 }

+ 4 - 7
frontend/src/components/LoginModal.tsx

@@ -2,6 +2,7 @@
 
 import { useState } from 'react'
 import { useAuth } from '@/hooks/useAuth'
+import Modal from '@/components/Modal'
 
 export default function LoginModal({ onClose }: { onClose: () => void }) {
   const { login } = useAuth()
@@ -20,12 +21,8 @@ export default function LoginModal({ onClose }: { onClose: () => void }) {
   }
 
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
-      <form
-        onSubmit={handleSubmit}
-        onClick={(e) => e.stopPropagation()}
-        className="w-full max-w-sm space-y-4 rounded-2xl bg-white/10 p-8 backdrop-blur-md"
-      >
+    <Modal onClose={onClose} size="sm" className="bg-white/10 backdrop-blur-md rounded-2xl">
+      <form onSubmit={handleSubmit} className="space-y-4 p-8">
         <h1 className="text-center text-2xl font-bold text-white">Вход</h1>
 
         {error && <p className="text-sm text-red-400">{error}</p>}
@@ -55,6 +52,6 @@ export default function LoginModal({ onClose }: { onClose: () => void }) {
           Войти
         </button>
       </form>
-    </div>
+    </Modal>
   )
 }

+ 40 - 0
frontend/src/components/Modal.tsx

@@ -0,0 +1,40 @@
+'use client'
+
+import { type ReactNode } from 'react'
+
+type ModalSize = 'sm' | 'md' | 'lg' | 'xl'
+
+const sizeClasses: Record<ModalSize, string> = {
+  sm: 'max-w-sm',
+  md: 'max-w-md',
+  lg: 'max-w-lg',
+  xl: 'max-w-2xl',
+}
+
+interface ModalProps {
+  onClose: () => void
+  children: ReactNode
+  size?: ModalSize
+  className?: string
+}
+
+/**
+ * Переиспользуемая модалка.
+ * Скроллбар всегда внутри контента (max-h-[90vh] overflow-y-auto),
+ * а не на оверлее.
+ */
+export default function Modal({ onClose, children, size = 'md', className = '' }: ModalProps) {
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
+      onClick={onClose}
+    >
+      <div
+        className={`w-full ${sizeClasses[size]} max-h-[90vh] overflow-y-auto rounded-xl bg-[#1e293b] shadow-xl ${className}`}
+        onClick={(e) => e.stopPropagation()}
+      >
+        {children}
+      </div>
+    </div>
+  )
+}

+ 4 - 6
frontend/src/components/RegisterModal.tsx

@@ -2,6 +2,7 @@
 
 import { useState, useEffect } from 'react'
 import { useAuth } from '@/hooks/useAuth'
+import Modal from '@/components/Modal'
 
 export default function RegisterModal({ onClose }: { onClose: () => void }) {
   const { register } = useAuth()
@@ -34,11 +35,8 @@ export default function RegisterModal({ onClose }: { onClose: () => void }) {
   }
 
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
-      <div
-        onClick={(e) => e.stopPropagation()}
-        className="w-full max-w-sm space-y-4 rounded-2xl bg-green-600/15 p-8 backdrop-blur-md"
-      >
+    <Modal onClose={onClose} size="sm" className="bg-green-600/15 backdrop-blur-md rounded-2xl">
+      <div className="p-8">
         {success ? (
           <div className="space-y-2 text-center">
             <p className="text-lg font-bold text-green-400">✓ Регистрация прошла успешно</p>
@@ -106,6 +104,6 @@ export default function RegisterModal({ onClose }: { onClose: () => void }) {
           </form>
         )}
       </div>
-    </div>
+    </Modal>
   )
 }

+ 88 - 0
obsidian_data/Photoplaces_data/MOC-frontend-patterns.md

@@ -0,0 +1,88 @@
+## MOC: Фронтенд-паттерны и компоненты PhotoPlaces
+
+### Архитектура фронтенда
+
+```mermaid
+graph TB
+    Next[Next.js 14 App Router] --> Pages[Pages]
+    Next --> Components[Shared Components]
+    Next --> Hooks[Custom Hooks]
+    Components --> Modal[Modal]
+    Components --> MapView[MapView + Leaflet]
+    Components --> PlaceForm[PlaceForm]
+    Hooks --> useAuth[AuthProvider + Context]
+    Hooks --> useWebSocket[WebSocket]
+    Pages --> API[api.ts: fetch wrapper]
+    API --> Auth[Auto-refresh 401]
+    API --> Backend[Go API :8080]
+```
+
+### Ключевые компоненты
+
+| Компонент | Файл | Назначение |
+|---|---|---|
+| Modal (общий) | `components/Modal.tsx` | Переиспользуемая модалка, скроллбар внутри контента |
+| MapView | `components/MapView.tsx` | Карта Leaflet + PlaceCardModal |
+| PlaceForm | `components/PlaceForm.tsx` | Форма места / студии / услуги |
+| PlaceCardModal | `MapView.tsx` (встроен) | Bottom-panel карточка места |
+| LoginModal | `components/LoginModal.tsx` | Форма входа |
+| RegisterModal | `components/RegisterModal.tsx` | Форма регистрации |
+| AddLocationModal | `components/AddLocationModal.tsx` | Добавление места |
+| ErrorBoundary | `components/ErrorBoundary.tsx` | Отлов ошибок рендера |
+| ErrorReporter | `components/ErrorReporter.tsx` | Панель ошибок (debug) |
+| MapPicker | `components/MapPicker.tsx` | Полноэкранный выбор координат |
+| Header | `components/Header.tsx` | Навигация |
+
+### Паттерны
+
+| Паттерн | Где | Заметка |
+|---|---|---|
+| **Shared Modal** с `overflow-y-auto` внутри | `components/Modal.tsx` | [[atomic-modal-scrollbar-inside]] |
+| Ad-hoc модалки → общий компонент (DRY) | decision record | [[decision-modal-component-extraction]] |
+| Auth через Context + custom hook | `hooks/useAuth.tsx` | [[frontend-api-client]] |
+| Ghost markers для гостей | `MapView.tsx:82-83` | [[atomic-ghost-markers]] |
+| Diff-обновление маркеров (markerIdsRef) | `MapView.tsx:70-87` | [[architecture-overview]] |
+| API client с auto-refresh токена | `lib/api.ts` | [[frontend-api-client]] |
+| Error suppression (антипаттерн) | `PlaceForm.tsx:42-43` | [[atomic-error-swallowing-frontend]] |
+| Fetch retry infinite loop (антипаттерн) | `lib/api.ts:87` | [[atomic-fetch-retry-infinite-loop]] |
+| Координаты [lat,lng] консистентность | `lib/map.ts` | [[atomic-map-coordinates-inconsistency]] |
+
+### Стек
+
+- **Фреймворк**: Next.js 14 App Router
+- **Стили**: Tailwind CSS (без CSS-модулей)
+- **Карты**: Leaflet + OSM dark tiles
+- **Состояние**: React Context (AuthProvider)
+- **API**: Custom fetch wrapper (`lib/api.ts`)
+- **Тесты**: Vitest + @testing-library/react
+
+### Code style
+
+- Все компоненты — `'use client'` (прямые функции, не классы)
+- Нет выделенных CSS/SCSS-файлов — только Tailwind utility классы в JSX
+- Импорты: `@/` алиас → `./src/`
+- Комментарии на русском языке
+- Нет сторонних UI-библиотек (MUI, Radix, Headless UI)
+
+### Антипаттерны (исправленные)
+
+| Проблема | Заметка |
+|---|---|
+| 7 ad-hoc модалок | [[atomic-modal-scrollbar-inside]] |
+| Подавление ошибок | [[atomic-error-swallowing-frontend]] |
+| Retry loop на /auth/logout | [[atomic-fetch-retry-infinite-loop]] |
+| Ghost markers — стиль разный от logged-in | [[atomic-ghost-markers]] |
+| Типы не синхронизированы с backend | [[atomic-frontend-types-backend-sync]] |
+
+### Связанные заметки
+
+- [[architecture-overview]] — общий обзор
+- [[frontend-api-client]] — API client и auth flow
+- [[atomic-modal-scrollbar-inside]] — модалка со скроллбаром
+- [[decision-modal-component-extraction]] — решение об извлечении Modal
+- [[atomic-error-swallowing-frontend]] — подавление ошибок
+- [[atomic-ghost-markers]] — ghost-маркеры на карте
+- [[MOC-backend-patterns]] — бэкенд-паттерны
+- [[MOC-security-patterns]] — безопасность
+
+#frontend #react #nextjs #MOC #best-practice #components

+ 4 - 1
obsidian_data/Photoplaces_data/architecture-overview.md

@@ -2,7 +2,7 @@
 
 **Контекст**: Карта архитектуры и технического долга проекта PhotoPlaces. Используется для планирования рефакторинга и оценки жизнеспособности.
 
-**Статус**: Заметка актуализирована 2026-07-01 после повторного code review и аудита документации.
+**Статус**: Заметка актуализирована 2026-07-04 после рефакторинга модалок.
 
 ## Суть
 
@@ -133,6 +133,9 @@ graph TB
 - [[atomic-extra-batch-pagination]] — Лишние batch-запросы при пагинации
 - [[atomic-n-plus-one-getbyid]] — N+1 в GetByID (places + services)
 - [[atomic-websocket-broadcast-double-lock]] — Двойной проход по clients в broadcastVisitors
+- [[atomic-modal-scrollbar-inside]] — Модалка со скроллбаром внутри контента
+- [[decision-modal-component-extraction]] — Извлечение общего компонента Modal
+- [[MOC-frontend-patterns]] — Фронтенд-паттерны
 
 ## Теги
 

+ 106 - 0
obsidian_data/Photoplaces_data/atomic-modal-scrollbar-inside.md

@@ -0,0 +1,106 @@
+# Модалка со скроллбаром внутри контента
+
+**Контекст**: В проекте не было переиспользуемого компонента Modal. Каждая модалка создавалась ad-hoc — дублирование разметки (`fixed inset-0 z-50` оверлей + `stopPropagation` + backdrop). При этом скроллбар либо отсутствовал (риск выхода контента за viewport), либо был на оверлее, а не на внутреннем контенте модалки.
+
+## Проблема
+
+### До рефакторинга
+
+```tsx
+// ❌ EditModal: scroll на ОВЕРЛЕЕ, а не на контенте
+<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto ..." onClick={onClose}>
+  <div className="w-full max-w-2xl ..." onClick={(e) => e.stopPropagation()}>
+    {/* контент без скролла */}
+  </div>
+</div>
+
+// ❌ LoginModal, RegisterModal, ReworkModal: вообще без overflow
+<div className="fixed inset-0 z-50 flex items-center justify-center ..." onClick={onClose}>
+  <form ... onClick={(e) => e.stopPropagation()}>
+    {/* может вылезти за viewport */}
+  </form>
+</div>
+```
+
+**Последствия**:
+- При высоком контенте (форма редактирования, список полей) скроллбар появляется на body/оверлее — пользователь видит скроллбар за пределами модалки
+- Модалки без `max-h` + `overflow-y-auto` могут выйти за границы экрана
+- Дублирование кода: 7 мест с идентичной логикой оверлея + `stopPropagation`
+
+## Решение
+
+Единый компонент `Modal` с `max-h-[90vh] overflow-y-auto` на **внутреннем** контейнере:
+
+```tsx
+export default function Modal({ onClose, children, size = 'md', className = '' }: ModalProps) {
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
+      onClick={onClose}
+    >
+      <div
+        className={`w-full ${sizeClasses[size]} max-h-[90vh] overflow-y-auto rounded-xl bg-[#1e293b] shadow-xl ${className}`}
+        onClick={(e) => e.stopPropagation()}
+      >
+        {children}
+      </div>
+    </div>
+  )
+}
+```
+
+### Что изменилось
+
+```mermaid
+graph LR
+    A["До: 7 ad-hoc модалок"] --> B["После: 1 компонент Modal"]
+    B --> C["max-h-[90vh] на inner div"]
+    C --> D["overflow-y-auto на inner div"]
+    D --> E["Скроллбар внутри модалки"]
+    B --> F["Размеры: sm/md/lg/xl"]
+    B --> G["className override для фона"]
+```
+
+### Ключевое различие
+
+| | До | После |
+|---|---|---|
+| Расположение `overflow-y-auto` | На оверлее (EditModal) или отсутствует | На внутреннем контейнере |
+| Скроллбар | Снаружи модалки (оверлей/body) | Внутри модалки |
+| DRY | Нарушен — 7 копий разметки | Соблюдён — 1 компонент |
+| Background override | Разный в каждом | `className` prop |
+
+## Переведённые модалки
+
+| Компонент | Файл | `size` | Примечание |
+|-----------|------|--------|------------|
+| EditModal | `app/places/my/page.tsx` | `xl` | Был главной проблемой |
+| LoginModal | `components/LoginModal.tsx` | `sm` | Кастомный фон через className |
+| RegisterModal | `components/RegisterModal.tsx` | `sm` | Кастомный фон через className |
+| AddLocationModal | `components/AddLocationModal.tsx` | `xl` | Упрощён, убран лишний wrapper |
+| ReworkModal | `app/admin/page.tsx` | `md` | Новый скролл |
+| CreateUserModal | `app/admin/users/page.tsx` | `sm` | Новый скролл |
+| Delete confirm (2 шт.) | `places/my`, `admin` | `sm` | Новый скролл |
+
+## Не затронуто
+
+- **PlaceCardModal** (`MapView.tsx`) — не модалка, а bottom panel
+- **MapPicker** — полноэкранный компонент
+
+## Принцип
+
+> Скроллбар должен быть частью модального контента, а не оверлея. Пользователь должен понимать границы модалки, и скроллбар внутри них это подчёркивает.
+
+## Связанные заметки
+
+- [[decision-modal-component-extraction]] — почему выбрали компонент, а не починку на месте
+- [[frontend-api-client]]
+- [[architecture-overview]]
+
+## Источник
+
+Задача 2026-07-04: «Если в модалке нужен скроллбар, то он должен быть внутри модалки».
+
+## Теги
+
+#frontend #react #modal #ux #refactoring #DRY

+ 63 - 0
obsidian_data/Photoplaces_data/decision-modal-component-extraction.md

@@ -0,0 +1,63 @@
+# Decision: Извлечение компонента Modal
+
+**Контекст**: В проекте 7 мест с ad-hoc модалками и проблема со скроллбаром на оверлее. Нужно было выбрать между починкой на месте и извлечением переиспользуемого компонента.
+
+## Альтернативы
+
+| Подход | Плюсы | Минусы |
+|--------|-------|--------|
+| **Исправить на месте** (добавить `max-h-[90vh] overflow-y-auto` в каждую) | Минимум изменений, нет нового кода | Нарушает DRY, будущие модалки снова ad-hoc, сложнее поддерживать единообразие |
+| **Извлечь компонент Modal** (выбрано) | DRY, единый источник стилей, новые модалки сразу правильные, упрощает рефакторинг | +1 файл, need migration 7 мест |
+| **Headless UI / Radix Dialog** | a11y из коробки, portal, focus trap | Зависимость, переписывание всех стилей, избыточно для текущего масштаба |
+
+## Выбор
+
+**Извлечение компонента Modal**. Почему:
+
+1. **DRY**: 7 копий одинакового оверлея + `stopPropagation` — явный код-смэлл
+2. **Единый источник истины**: скроллбар теперь всегда внутри контента (`max-h-[90vh] overflow-y-auto` на inner div)
+3. **Расширяемость**: новые размеры (`sm/md/lg/xl`), `className` override для кастомного фона
+4. **Простота**: без зависимостей, чистый Tailwind
+5. **Будущие модалки**: больше не будут дублировать разметку
+
+### Компромиссы
+
+- Не добавили portal / focus trap / escape-key (a11y) — оставлено на будущее
+- Не использовали готовую библиотеку (Headless UI/Radix) — не переусложняем
+
+## Реализация
+
+Файл: `frontend/src/components/Modal.tsx`
+
+```tsx
+// Ключевая строка — скроллбар внутри контента
+className={`w-full ${sizeClasses[size]} max-h-[90vh] overflow-y-auto ... ${className}`}
+```
+
+## Результат
+
+```mermaid
+graph TD
+    A[7 ad-hoc модалок<br/>разный scroll/no-scroll] --> B[1 компонент Modal]
+    B --> C[Скроллбар внутри модалки]
+    B --> D[Единый стиль]
+    B --> E[Будущие модалки = 1 строчка]
+```
+
+- Удалено: ~30 строк дублированной разметки (по 4-5 строк на каждую модалку)
+- Добавлено: 1 компонент, 40 строк
+- Чистый профит: +10 строк, но DRY и гарантированный скроллбар
+
+## Связанные заметки
+
+- [[atomic-modal-scrollbar-inside]] — детали рефакторинга
+- [[architecture-overview]]
+- [[frontend-api-client]]
+
+## Источник
+
+Задача 2026-07-04: «Если в модалке нужен скроллбар, то он должен быть внутри модалки».
+
+## Теги
+
+#decision #frontend #react #modal #DRY #refactoring