Добавляем каталогу обложки из медиатеки проекта

Первые три части цикла собрали закрытую админку каталога: вход по member-куке, аккаунты с ролями, JSON-API с фильтрами и честными ошибками. Осталась последняя крупная деталь интерфейса — обложки. Книга в списке без обложки читается, но каталог из десяти таких книг выглядит как таблица из бухгалтерии.
В этой части даём каталогу обложки и разбираемся, что при этом происходит с файлами: где они живут, кто и как может их добавить, и почему записей о файлах у нас получится добавить сколько угодно, а байтов — ровно столько, сколько позволяет тариф.
Если вы только присоединились: весь цикл строит один демо-проект — каталог городской библиотеки. Первый шаг описан в части про вход и JSON-API, аккаунты с подтверждением почты — во второй, валидация и отладка эндпоинтов — в третьей.
Где живут файлы
В проекте с самого начала есть тип данных media. Он не создан нами — как navigation, он засеивается платформой при создании проекта: у записи есть name, url, type, width и height, а раздел «Медиа» в админке — обычный редактор этого типа поверх загрузчика.
Файл, который вы загружаете, уезжает в хранилище проекта, а запись получает его адрес:

Загрузить файлы можно через кнопку «Загрузить файл», а можно автоматизировать — у flowctl есть подкоманда media push, которая делает то же самое из командной строки:
flowctl media push covers/*.png
Каждая строка вывода — это id записи и адрес файла. Адрес — то, что получают браузеры читателей, когда показывают картинку; id записи ещё встретится, когда книга сошлётся на обложку.
Поле-ссылка, а не копия
Теперь книге нужно поле для обложки. В редакторе типа book добавляем поле cover с типом «Тип данных» и целью media:

Схема перестраивается, и у каждой книги появляется поле, которое хранит id записи из медиатеки. Это различие стоит проговорить: байты файла лежат в хранилище в одном экземпляре, а книга держит только id. Десять книг с одной обложкой не занимают в десять раз больше места — все они ссылаются на один и тот же файл.
Ссылка живая. Откройте у любой записи медиатеки удаление — платформа покажет, что с ней связано:

Если файл всё же удалить, платформа не оставит книгам битые картинки: все ссылки на него обнулятся, книга останется, обложка станет пустой. Удаление файла вернёт его байты в квоту хранилища.
Медиатека в своей админке
Библиотекарь работает в нашей админке — значит, и обложки он выбирает, не покидая каталог. Добавим эндпоинт, который отдаёт содержимое медиатеки. Сценарий media_list на маршруте GET /api/media состоит из двух узлов: graphql-запрос и ответ:
{
"query": "query {\n medias(limit: 60, orderBy: [{field: CREATED_AT, direction: DESC}]) {\n id name url type width height\n }\n}",
"result_key": "q"
}
medias — обычный автосгенерированный запрос для типа media: свежие записи вверху. Ответ отдаём как есть:
{ "items": {{ results.q.medias | json }} }
(Форма с body_template и | json — наследие: с v1.4.16 Liquid в «Теле (шаблон)» «JSON-ответа» удалён из движка, ключ стал статичной строкой, и такая запись уехала бы клиенту буквально, сломав JSON. Телу достаточно "body_expr": "{ items: results.q.medias }" — значение кодируется как данные, без экранирования.)
Маршрут помечен membersOnly — медиатека проекта видна только вошедшим сотрудникам, как и остальное API каталога.
Обложка по ссылке
Файл с диска попадает в проект через авторизованную загрузку — этот путь остаётся за админкой платформы. Но записи в медиатеке бывают разные: кроме загруженных файлов, в ней могут жить ссылки на картинки, уже размещённые в интернете. И такую запись можно создавать из сценария — это обычный createMedia, тот же класс мутации, что и createBook из третьей части.
Сценарий media_add на POST /api/media проверяет два поля и создаёт запись:
{
"id": "a_create",
"type": "action",
"data": {
"action_type": "graphql",
"config": {
"query": "mutation($name: String!, $url: String!) {\n createMedia(input: {name: $name, url: $url, type: \"image\"}) {\n id name url\n }\n}",
"variables": {
"name": "{{json.name | strip}}",
"url": "{{json.url | strip}}"
}
}
}
}
Перед мутацией — два условия с ветками на 400: у адреса должна быть схема https (json.url соответствует ^https://\S+$), у имени не должно быть пустых символов. Ответы об ошибках строятся по образцу третьей части: {"error": "VALIDATION", "field": "url", "message": "..."}, чтобы фронтенд знал, какое поле подсветить.

Обратите внимание, чего в этом сценарии нет: байтов. Через сценарий идёт строка с адресом. Файловый протокол multipart, которым браузер загружает файлы в «Медиа», в сценариях сайта не участвует — и это осознанная граница: сценарии работают со строками и данными, бинарные файлы попадают в хранилище только через авторизованную загрузку.
Запись по ссылке не расходует хранилище: у неё нет байтов, платформа хранит только адрес. Квоту расходует загрузка.
Пикер в форме
Во фронтенде появляется второй источник данных. Рядом с каталогом грузим медиатеку:
const covers = ref<MediaItem[]>([]);
async function loadCovers() {
const res = await fetch("/api/media", { headers: { Accept: "application/json" } });
if (!res.ok) return;
covers.value = (await res.json()).items ?? [];
}
В форме добавления книги появляется селект: «Без обложки» плюс всё содержимое библиотеки. Кнопка «В библиотеку» рядом отправляет адрес через сценарий из прошлого шага и сразу выбирает новую запись:

Сценарий добавления книги принимает id обложки. Проверка на его корректность такая же придирчивая, как в третьей части: пустое значение проходит (обложка не обязательна), непустое должно быть UUID — иначе 400 с полем cover:
{ "field": "json.cover", "operator": "matches",
"value": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" }
Мутация создаёт книгу и сразу возвращает обложку вложенным объектом:
{
"query": "mutation($title: String!, $author: String!, $year: Int, $status: String, $cover: ID) {\n createBook(input: {title: $title, author: $author, year: $year, status: $status, cover: $cover}) {\n id title author year status\n cover { id url }\n }\n}",
"variables": {
"cover": "{{json.cover}}"
}
}
Поле-ссылка в схеме — это полноценный объект: у него можно запрашивать id, url и всё остальное, что есть у записи медиатеки. Список книг в books_list выбирает те же cover { id url }, и таблица получает колонку с миниатюрами:

Книга без обложки — не ошибка: заглушка показывает, что поле просто не заполнено.
Файлом с диска
Ссылка не единственный путь. В строке «Новая обложка» можно выбрать файл с диска: он уезжает multipart-запросом на POST /api/media/upload, и платформа сама кладёт его в медиатеку проекта.
У этого маршрута включён тумблер «Принимать файлы». По умолчанию он выключен: сценарий без тумблера отвергает файл до всякого сохранения с кодом FORM_FILES_NOT_ACCEPTED. Разрешённый файл приезжает в сценарий готовой записью медиатеки:
{ "media": { "id": "…", "url": "https://…", "name": "cover-pikovaya-dama.png",
"mime_type": "image/png", "file_size": 28110, "type": "image" } }
Админка отправляет FormData, забирает media.id и сразу подставляет свежую запись в пикер: обложка выбирается сама, без «Медиа» и без ссылок. Капы платформенные: 10 МБ на файл, до 10 файлов в одной заявке, квота проверяется до записи.
Сколько байтов можно загрузить
Хранилище — тарифная квота, и она честная в обе стороны. При загрузке платформа проверяет квоту до того, как файл уедет в хранилище: места не хватает — загрузка отклоняется. При удалении файла байты возвращаются. Цифры — из тарифов: 100 МБ на «Бесплатном», 2 ГБ на «Стартовом», 6 ГБ на «Профессиональном», 20 ГБ на «Бизнесе».
Сценарии сайта теперь тоже участвуют: у маршрута с тумблером «Принимать файлы» байты заявки идут в квоту, и проверка проходит до записи (предыдущий раздел). Эндпоинт POST /api/media добавляет записи-ссылки, которые весят ноль; «Медиа» и media push принимают файлы напрямую, квота проверяется так же.
Практический вывод для админки: даже если сотрудник добавит сто обложек по ссылке, счётчик хранилища не дрогнет. Оплачиваются файлы, а не записи о них.
Что осталось за кадром
У записи media есть поле alt_text, и оно локализованное — карта {ru, en}. Обложка одна, подписи к ней — две. Это первая встреча с локализацией в нашем каталоге, и ей посвящена следующая часть цикла.
Все .dynflow.json этой части лежат в ветке step-7-media, применяются командой flowctl apply flows/media_list.dynflow.json --publish. Каталог из статьи — живой проект: biblio.dynapi.ru встречает входом, читатель reader@biblio.test с паролем chitalka-2026 — ползайте по админке, обложки настоящие.
Все записи