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

Первые три части цикла собрали закрытую админку каталога: вход по 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 }} }
Маршрут помечен 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 }, и таблица получает колонку с миниатюрами:

Книга без обложки — не ошибка: заглушка показывает, что поле просто не заполнено.
Сколько байтов можно загрузить
Хранилище — тарифная квота, и она честная в обе стороны. При загрузке платформа проверяет квоту до того, как файл уедет в хранилище: места не хватает — загрузка отклоняется. При удалении файла байты возвращаются. Цифры — из тарифов: 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 — ползайте по админке, обложки настоящие.
Все записи