Перейти к содержанию

Серверные действия для закладок

Поиск «Маршрута» использует GET, потому что только выбирает общедоступные уроки. Закладка меняет данные читателя и требует другого пути. Введём серверное действие: форма передаёт идентификатор урока и желаемое состояние, сервер проверяет запрос, изменяет репозиторий владельца и возвращает результат.

Эта глава определяет контракт записи. Интерфейс репозитория приведён полностью, а конкретное учебное хранение будет добавлено в двенадцатом уроке и в конечных исходниках. До этого действие нельзя считать самостоятельно запускаемым проектом. Здесь программы не выполнялись; демонстрация не утверждает, что закладка уже сохранена в действующем аккаунте ProfessorWeb.

Команда вместо переворота значения

Локальный toggle второго урока читал текущий массив и менял отметку на противоположную. В сети запрос может повториться после потери ответа. Если сервер каждый раз переворачивает значение, два одинаковых POST отменят первоначальное действие. Команда «установить закладку» лучше переносит повторную доставку.

Поля формы поэтому содержат lessonId и desired. Значение 1 означает установленную закладку, 0 — снятую. Для одной команды повтор должен приводить к тому же состоянию. Это предметная идемпотентность операции; она не обещает автоматически одинакового HTTP-ответа или защиты всей цепочки от любых повторов.

Контракт будущего модуля src/lib/server/repository.ts включает два метода. Сервер передаёт пользователя отдельно от полей формы.

export type ReaderState = { bookmarks: string[]; completed: string[]; version: number };
export interface ReaderRepository {
  read(readerId: string): Promise<ReaderState>;
  setBookmark(readerId: string, lessonId: string, desired: boolean): Promise<void>;
}

Не принимайте readerId из скрытого поля. Скрытое поле скрыто только визуально, его можно изменить при отправке. Пользователь поступает из доверенной серверной сессии, которая позже заполнит locals.reader. Без этого источника форма должна отказать, а не подставить общего демонстрационного владельца.

Именованное действие страницы

Полный пример экспортируемых действий размещается в src/routes/account/+page.server.ts. Он использует репозиторий, реализованный позднее, и тип locals, который определим вместе с сессией. Проверки остаются в серверном пути, независимо от видимости кнопки.

import { fail } from '@sveltejs/kit';
import { lessons } from '$lib/catalog';
import { repository } from '$lib/server/repository';
import type { Actions } from './$types';
export const actions: Actions = {
  bookmark: async ({ request, locals }) => {
    if (!locals.reader) return fail(401, { message: 'Войдите в кабинет.' });
    const form = await request.formData();
    const lessonId = form.get('lessonId');
    const desired = form.get('desired');
    if (typeof lessonId !== 'string' || !lessons.some(x => x.id === lessonId))
      return fail(400, { message: 'Неизвестный урок.' });
    if (desired !== '0' && desired !== '1')
      return fail(400, { message: 'Неверное состояние закладки.' });
    await repository.setBookmark(locals.reader.id, lessonId, desired === '1');
    return { success: true, message: 'Закладка сохранена.' };
  }
};

Отсутствие сессии обрабатывается раньше чтения содержимого формы. Параметр lessonId проверяется как строка, потому что FormData допускает также файл. Затем он сверяется с каталогом. desired ограничен двумя значениями, а не превращается через Boolean, который сделал бы строку '0' истинной.

fail возвращает ожидаемую неудачу с подходящим статусом и данными формы. Ошибка хранилища не маскируется этим же сообщением: для неё позже понадобится отдельный обработчик. Успех возвращается только после завершения await. Преждевременная надпись «Сохранено» до подтверждения репозитория создала бы ложную уверенность.

Нативная POST-форма

На странице кабинета форма использует именованное действие ?/bookmark. Обычный браузер способен отправить её без дополнительного JavaScript. Фрагмент вставляется в карточку кабинета, где lesson означает текущий урок цикла, а saved вычислен из загруженного набора закладок.

<form method="POST" action="?/bookmark">
  <input type="hidden" name="lessonId" value={lesson.id}>
  <input type="hidden" name="desired" value={saved ? '0' : '1'}>
  <button aria-pressed={saved}>
    {saved ? 'Убрать закладку' : 'Сохранить урок'}
  </button>
</form>

Именованные и default actions нельзя произвольно смешивать на одной странице. Мы выбираем имена, потому что позднее появятся завершение урока и изменение профиля. Модель form actions и fail описана официально. Контракт конкретной команды и проверки каталога определены нашим приложением.

Ожидаемый путь без клиентского улучшения таков: браузер отправляет POST, сервер изменяет состояние, после действия выполняется загрузка страницы, новый HTML содержит актуальную кнопку. Включение JavaScript позже улучшит этот путь, сохранив ту же проверку и тот же репозиторий.

Повтор и конкурирующая вкладка

Команда установки безопаснее переключения, но старая вкладка всё равно может отправить устаревшее намерение. Допустим, одна вкладка сняла закладку, а другая показывает прежнюю кнопку снятия. Повтор desired=0 оставит закладку снятой; здесь результат понятен. Более сложное редактирование текста потребовало бы контроля версии и ответа о конфликте.

Для учебных закладок достаточно правила «последняя принятая команда задаёт состояние». Прогресс завершения использует тот же подход. Мы не объявляем это универсальным решением для платежа или списания средств: у таких операций другие последствия повторного выполнения и другие идентификаторы команд.

Обновление страницы после записи не должно забывать фильтр или возвращать чужую историю. Поэтому загрузка кабинета читает данные из того же доверенного пользователя, что действие. Проверка владельца только в load была бы недостаточна: прямой POST может обходить посещение страницы.

Подготовка следующего слоя

На этом этапе понятны параметры запроса и ответ сервера. Конкретный репозиторий ещё не подключён, а пример пользователя не заменяет настоящую сессию. Такая промежуточная граница позволяет обсудить запись отдельно, не выдавая макет хранения за доступ к действующему аккаунту.

Команда завершения использует тот же путь

Добавление завершения не требует нового метода HTTP или доверия к браузеру. Создайте именованное действие complete с теми же проверками сессии, lessonId и desired, затем вызовите repository.setComplete. Отличается предметное поле, а граница владельца остаётся прежней. Это содержательное повторное применение механизма, а не копирование непроверенного обработчика.

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

Ответ содержит сообщение, но не обязан возвращать всю историю. Следующая загрузка получает её из репозитория. Такой порядок уменьшает число независимых форматов состояния и сохраняет один источник истины. При будущем отдельном API можно выбрать другой ответ, однако его контракт нужно определить явно.

Наконец, скрытие кнопки до входа остаётся удобством интерфейса. Действие не полагается на то, что никто не знает адрес ?/complete. Эта форма адреса видна в HTML и является обычной частью приложения; безопасность строится на доверенной сессии и серверной проверке операции.

В ответе действия полезно сохранять безопасный признак выполненной операции. Он помогает связать статус с нужной формой при нескольких кнопках. Такой признак служит отображению результата и не заменяет серверную проверку пользователя или подтверждение репозитория.

Следующий урок применит fail к редактированию имени: сохранит введённое значение, свяжет ошибку с полем и объяснит различие браузерной и серверной валидации. Затем добавим use:enhance, чтобы форма оставалась рабочей при обычном POST и получала более удобное ожидание при клиентском улучшении.

Оглавление серии