diff --git a/CHANGELOG.md b/CHANGELOG.md index a437f43f..46fbf5a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## [0.5.9] - 2025-06-30 + +### Новая функциональность CRUD коллекций + +- **НОВОЕ**: Полноценное управление коллекциями в админ-панели: + - **Новая вкладка "Коллекции"**: Отдельная секция в админ-панели для управления коллекциями + - **Полная CRUD функциональность**: Создание, редактирование, удаление коллекций + - **Подробная таблица**: ID, название, slug, описание, создатель, количество публикаций, даты создания и публикации + - **Клик для редактирования**: Нажатие на строку открывает модалку редактирования коллекции + - **Удаление с подтверждением**: Тонкая кнопка "×" для удаления с модальным окном подтверждения + - **Кнопка создания**: Возможность создания новых коллекций прямо из интерфейса + +- **Серверная часть**: + - **GraphQL схема**: Новые queries, mutations и input types для коллекций + - **Резолверы**: Полный набор резолверов для CRUD операций (create_collection, update_collection, delete_collection, get_collections_all) + - **Авторизация**: Требуется роль editor или admin для создания/редактирования/удаления коллекций + - **Валидация прав**: Создатель коллекции или admin/editor могут редактировать коллекции + - **Cascading delete**: При удалении коллекции удаляются все связи с публикациями + - **Подсчет публикаций**: Автоматический подсчет количества публикаций в коллекции + +- **Архитектурные улучшения**: + - **Модель Collection**: Добавлен relationship для created_by_author + - **Базы данных**: Включены таблицы Collection и ShoutCollection в создание схемы + - **Type safety**: Полная типизация для TypeScript в админ-панели + - **Переиспользование паттернов**: Следование существующим паттернам для единообразия + +### Исправления SPA роутинга + +- **КРИТИЧНО ИСПРАВЛЕНО**: Проблема с роутингом админ-панели: + - **Проблема**: Переходы на `/login`, `/admin` и другие маршруты возвращали "Not Found" вместо корректного отображения SPA + - **Причина**: Сервер искал физические файлы для каждого маршрута вместо делегирования клиентскому роутеру + - **Решение**: + - Добавлен SPA fallback обработчик `spa_handler()` в `main.py` + - Все неизвестные GET маршруты теперь возвращают `index.html` + - Клиентский роутер SolidJS получает управление и корректно обрабатывает маршрутизацию + - Разделены статические ресурсы (`/assets`) и SPA маршруты + - **Результат**: Админ-панель корректно работает на всех маршрутах (`/`, `/login`, `/admin`, `/admin/collections`) + +- **Архитектурные улучшения**: + - **Правильное разделение обязанностей**: Сервер обслуживает API и статику, клиент управляет роутингом + - **Добавлен FileResponse импорт**: Для корректной отдачи HTML файлов + - **Оптимизированная конфигурация маршрутов**: Четкое разделение между API, статикой и SPA fallback + - **Совместимость с SolidJS Router**: Полная поддержка клиентского роутинга + ## [0.5.8] - 2025-06-30 ### Улучшения интерфейса публикаций diff --git a/main.py b/main.py index 685d22a0..965c732e 100644 --- a/main.py +++ b/main.py @@ -9,7 +9,7 @@ from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request -from starlette.responses import JSONResponse, Response +from starlette.responses import FileResponse, JSONResponse, Response from starlette.routing import Mount, Route from starlette.staticfiles import StaticFiles @@ -108,6 +108,25 @@ async def graphql_handler(request: Request) -> Response: return JSONResponse({"error": str(e)}, status_code=500) +async def spa_handler(request: Request) -> Response: + """ + Обработчик для SPA (Single Page Application) fallback. + + Возвращает index.html для всех маршрутов, которые не найдены, + чтобы клиентский роутер (SolidJS) мог обработать маршрутинг. + + Args: + request: Starlette Request объект + + Returns: + FileResponse: ответ с содержимым index.html + """ + index_path = DIST_DIR / "index.html" + if index_path.exists(): + return FileResponse(index_path, media_type="text/html") + return JSONResponse({"error": "Admin panel not built"}, status_code=404) + + async def shutdown() -> None: """Остановка сервера и освобождение ресурсов""" logger.info("Остановка сервера") @@ -232,7 +251,12 @@ app = Starlette( # OAuth маршруты Route("/oauth/{provider}", oauth_login, methods=["GET"]), Route("/oauth/{provider}/callback", oauth_callback, methods=["GET"]), - Mount("/", app=StaticFiles(directory=str(DIST_DIR), html=True)), + # Статические файлы (CSS, JS, изображения) + Mount("/assets", app=StaticFiles(directory=str(DIST_DIR / "assets"))), + # Корневой маршрут для админ-панели + Route("/", spa_handler, methods=["GET"]), + # SPA fallback для всех остальных маршрутов + Route("/{path:path}", spa_handler, methods=["GET"]), ], middleware=middleware, # Используем единый список middleware lifespan=lifespan, diff --git a/orm/collection.py b/orm/collection.py index 58eaf1e7..3587e7e4 100644 --- a/orm/collection.py +++ b/orm/collection.py @@ -1,6 +1,7 @@ import time from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy.orm import relationship from services.db import BaseModel as Base @@ -8,7 +9,6 @@ from services.db import BaseModel as Base class ShoutCollection(Base): __tablename__ = "shout_collection" - id = None # type: ignore shout = Column(ForeignKey("shout.id"), primary_key=True) collection = Column(ForeignKey("collection.id"), primary_key=True) @@ -23,3 +23,5 @@ class Collection(Base): created_at = Column(Integer, default=lambda: int(time.time())) created_by = Column(ForeignKey("author.id"), comment="Created By") published_at = Column(Integer, default=lambda: int(time.time())) + + created_by_author = relationship("Author", foreign_keys=[created_by]) diff --git a/package.json b/package.json index 31e5370e..23f10ace 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "publy-panel", - "version": "0.5.8", + "version": "0.5.9", "private": true, "scripts": { "dev": "vite", diff --git a/panel/admin.tsx b/panel/admin.tsx index 0dda19bd..23211922 100644 --- a/panel/admin.tsx +++ b/panel/admin.tsx @@ -9,6 +9,7 @@ import publyLogo from './assets/publy.svg?url' import { logout } from './context/auth' // Прямой импорт компонентов вместо ленивой загрузки import AuthorsRoute from './routes/authors' +import CollectionsRoute from './routes/collections' import CommunitiesRoute from './routes/communities' import EnvRoute from './routes/env' import ShoutsRoute from './routes/shouts' @@ -133,6 +134,12 @@ const AdminPage: Component = (props) => { > Сообщества + + + + + + +
+
Загрузка коллекций...
+
+ } + > + + + + + + + + + + + + + + + + + {(collection) => ( + openEditModal(collection)} + style={{ cursor: 'pointer' }} + class={styles['clickable-row']} + > + + + + + + + + + + + )} + + +
IDНазваниеSlugОписаниеСоздательПубликацииСозданоОпубликованоДействия
{collection.id}{collection.title}{collection.slug} +
+ {collection.desc || '—'} +
+
{collection.created_by.name || collection.created_by.email}{collection.amount}{formatDate(collection.created_at)}{collection.published_at ? formatDate(collection.published_at) : '—'} e.stopPropagation()}> + +
+
+ + {/* Модальное окно создания */} + setCreateModal(false)} title="Создание новой коллекции"> +
+
+ + setFormData((prev) => ({ ...prev, slug: e.target.value }))} + style={{ + width: '100%', + padding: '8px', + border: '1px solid #ddd', + 'border-radius': '4px' + }} + required + placeholder="my-collection" + /> +
+ +
+ + setFormData((prev) => ({ ...prev, title: e.target.value }))} + style={{ + width: '100%', + padding: '8px', + border: '1px solid #ddd', + 'border-radius': '4px' + }} + required + placeholder="Моя коллекция" + /> +
+ +
+ +