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

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

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

Редактор типа «Книга»: список полей с новым полем cover типа media рядом с title, author, year и status

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

Ссылка живая. Откройте у любой записи медиатеки удаление — платформа покажет, что с ней связано:

Диалог удаления обложки с предупреждением «Используется в 1 записи» и таблицей ссылок: book, свойство cover

Если файл всё же удалить, платформа не оставит книгам битые картинки: все ссылки на него обнулятся, книга останется, обложка станет пустой. Удаление файла вернёт его байты в квоту хранилища.

Медиатека в своей админке

Библиотекарь работает в нашей админке — значит, и обложки он выбирает, не покидая каталог. Добавим эндпоинт, который отдаёт содержимое медиатеки. Сценарий 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": "..."}, чтобы фронтенд знал, какое поле подсветить.

Редактор сценария media_add: от «Начала» через условия проверки https и имени к узлу createMedia и трём JSON-ответам, маршрут /api/media

Обратите внимание, чего в этом сценарии нет: байтов. Через сценарий идёт строка с адресом. Файловый протокол 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 — ползайте по админке, обложки настоящие.


Все записи