Frontend · Nuxt 4 / Vue 3

Frontend — core-web

Детальный разбор клиентского приложения PrimeTime. Версия в проде: https://primetime.su/. Обзор всей системы — в about.md.

01 Технологическое ядро

КатегорияВыбор
Мета-фреймворкNuxt 4.3 (compatibilityVersion: 4), SSR-режим
UIVue 3, Composition API + <script setup>
ЯзыкTypeScript 5
СостояниеPinia (app/stores/**)
ТранспортgRPC-Web через @connectrpc/connect-web (бинарный формат)
СтилиSCSS по архитектуре 7-1 (Sass Guidelines)
ВалидацияVeeValidate 4 + Yup
Пакетный менеджерBun (bun.lock), плюс npm-lock как фолбэк
УтилитыVueUse, es-toolkit/lodash-es, mitt (event bus), consola (логи)

Версия пакета — 4.1.3. Сборка кодогенерации API и gRPC вынесена в скрипты (bun run generate:api, buf generate).

02 Структура и философия архитектуры

Приложение следует feature-folder / layered-подходу. Каждая папка имеет строго определённую зону ответственности (документировано в docs/architecture.md):

app/ ├── api/ # Типизированные обёртки над gRPC-вызовами (АВТО-ИМПОРТ) ├── components/ │ ├── core/ # «Каркасные» компоненты (scope.vue — «god»-инициализатор) │ ├── ui/ # Переиспользуемые примитивы (input, select, catalog/view...) │ ├── entity/ # Компоненты бизнес-сущностей (карточки фильмов, персон) │ ├── layout/ # Каркас страниц (header, aside, provider на слотах) │ ├── dialog/ # Модальные окна │ └── template/ # Готовые блоки интерфейса ├── composables/ # Vue-логика состояния/поведения (34 файла) ├── stores/ # Pinia-сторы ├── pages/ # Файловый роутинг (37 страниц) ├── plugins/ # Порядковые плагины инициализации (1..4 + клиентские) ├── utils/ # ЧИСТЫЕ функции, НЕ зависящие от vue/nuxt ├── assets/ # SCSS (7-1), иконки (SVG), шрифты └── grpc/generated # Сгенерированный из proto клиент modules/icon/ # Кастомный Nuxt-модуль генерации типов иконок shared/ # constants / models / services / types (авто-импорт) server/ # Nitro: sitemap-роуты, серверные middleware

Граница между utils/ и composables/ проведена осознанно и задокументирована: в utils/ — только универсальные функции, не привязанные к Vue/Nuxt; всё состояние и реактивность — в composables/. Это признак зрелого понимания сепарации.

Авто-импорты как осознанный приём

В nuxt.config.ts папка app/api/** добавлена в imports.dirs наравне с composables и shared. То есть API-обёртки работают как composable — их можно вызывать без явного импорта. Это снижает шум в страницах и унифицирует доступ к данным.

// nuxt.config.ts
imports: {
  dirs: ['./api/**', './composables/**', './utils/**', '../shared/**'],
},
Ключевой раздел

03 Фирменная liquid-вёрстка: 1rem = 1px

Самое нестандартное и интересное решение фронта — флюидная адаптивная сетка, в которой 1rem физически равен 1px при референсной ширине макета, а весь интерфейс плавно масштабируется вместе с шириной вьюпорта.

Механика

Корневой font-size переопределён на vw-выражение (app/assets/stylesheets/base/_liquid.scss):

html {
  --liquid-base: calc(1vw / 3.6);        // mobile (макет 360px)

  @include bp-tablet  { --liquid-base: calc(1vw / 7.68); }  // макет 768px
  @include bp-desktop { --liquid-base: calc(1vw / 19.2); }  // макет 1920px
}

html { font-size: var(--liquid-base); }
body { font-size: 16rem; }

Математика: на вьюпорте 360px 1vw = 3.6px, значит font-size = 3.6 / 3.6 = 1px. Следовательно 1rem = 1px. На планшете при 768px: 1vw = 7.68px, 7.68 / 7.68 = 1px. На десктопе 1920px: 19.2 / 19.2 = 1px.

Что это даёт

1. Дизайнер рисует макет в пикселях (360 / 768 / 1920), а верстальщик пишет ровно те же числа в remfont-size: 36rem для h1 = «36px по макету». Когнитивная нагрузка нулевая, расхождений «макет ↔ код» нет.

2. Весь интерфейс пропорционально «дышит» при изменении ширины окна между брейкпоинтами — без media-query на каждый размер. Один раз сверстал — масштабируется автоматически.

3. Брейкпоинты переключают только масштабную базу и крупные перестроения раскладки.

Пример из типографики (base/_typography.scss):

h1 {
  font-size: 36rem;          // = 36px на мобильном макете
  @include bp-tablet  { font-size: 48rem; }
  @include bp-desktop { font-size: 72rem; }
}

Это элегантное и довольно редкое в индустрии решение, которое полностью снимает класс проблем «попиксельного» соответствия дизайну. Сопровождается системой spacing-функций (spacing(6) = 12rem) и liquid-переменных раскладки (--liquid-header-height: 56rem, --liquid-aside-width: 280rem).

04 Стили: 7-1, темизация на CSS-переменных, BEM

Структура SCSS следует 7-1 pattern (abstracts / base / components / layout / pages / themes / vendors), на что прямо ссылается README со ссылкой на Sass Guidelines.

Темизация — целиком на CSS Custom Properties (themes/_base.scss): тёмная тема по умолчанию задаёт ~90 семантических токенов:

html {
  --theme-background: #121212;
  --theme-text-primary: #ffffff;
  --theme-accent: #ff0000;
  --theme-badge-series: #10b981;
  --theme-btn-primary-bg: linear-gradient(135deg, #cc0000, #990000);
  /* ...~90 токенов: фоны, тексты, бейджи, тени, оверлеи, состояния... */
}

Компоненты никогда не используют хардкод-цвета — только var(--theme-*). Это готовая инфраструктура под светлую тему (заготовка _light-alpha.scss уже есть).

Способ вёрстки компонентов — BEM (app-page-home__section--personal), scoped-free SCSS внутри <style lang="scss">, ширину диктует родитель (внутри UI-компонентов нет width: 100% — осознанное правило из docs/architecture.md). Адаптив — через миксины bp-tablet / bp-desktop поверх mobile-first базы.

05 Плагины: порядковая инициализация

Плагины пронумерованы, что задаёт детерминированный порядок и зависимости через dependsOn:

ПлагинРоль
1.initialization.tsЗапуск глобальной инициализации (валидация токена, конфиг сайта). Разделяет global / serverOnly / clientOnly фазы.
2.eventBus.tsОбёртка над mitt для глобального эмиттера
3.fetchInstance.tsПреднастроенный $fetch-инстанс с хуками
4.grpcInstance.ts★ gRPC-Web транспорт + клиенты всех 22 сервисов
marketing.client.tsКлиентская маркетинг-аналитика
vue-final-modal.client.tsПодключение системы модалок

Инициализация умеет различать SSR/клиент:

// 1.initialization.ts
await initializationGlobal()
await useServerOnlyAsync(initializationServerOnly)
if (!$config.public.NUXT_SSR) { await initializationServerOnly() }
await useClientOnlyAsync(initializationClientOnly)
Ключевой раздел

06 gRPC-клиент с интерсепторами (auth + retry)

4.grpcInstance.ts — один из самых сильных файлов фронта. Он создаёт gRPC-Web-транспорт и оборачивает все вызовы двумя интерсепторами.

Auth-интерсептор добавляет Authorization: Bearer из стора/менеджера:

const authInterceptor: Interceptor = next => async (req) => {
  const token = refreshManager.getAccessToken() || userStore.accessToken
  if (token) req.header.set('Authorization', `Bearer ${token}`)
  return await next(req)
}

Retry-интерсептор — обрабатывает протухший токен и device-id:

const retryInterceptor: Interceptor = next => async (req) => {
  try {
    return await next(req)
  } catch (error) {
    const connectError = ConnectError.from(error)
    if (connectError.code === Code.Unauthenticated) {
      // Revoke не рефрешим (иначе рекурсия)
      if (req.service === Services.AuthService && req.method.name === 'Revoke') throw error
      // Повторяем запрос РОВНО один раз (WeakMap-метка), после refresh
      if (!refreshedRequests.has(req)) {
        refreshedRequests.set(req, true)
        const newToken = await refreshManager.runRefreshFlow()
        if (newToken) {
          req.header.set('Authorization', `Bearer ${newToken}`)
          return await next(req)
        }
      }
      throw error
    }
    if (connectError.message.includes('A403')) {      // нет device-id
      await deviceStore.getDeviceClientId()
      return await next(req)
    }
    throw error
  }
}

Тонкости, выдающие зрелость:

  • Single-flight refreshcreateRefreshManager дедуплицирует параллельные refresh-запросы в один refreshPromise, чтобы 10 одновременных 401 не вызвали 10 обновлений токена. На SSR промис намеренно не обнуляется (см. заметку в auth-памяти).
  • WeakMap<req, boolean> гарантирует ровно одну повторную попытку на запрос — без бесконечного цикла рефреша.
  • Особый кейс A403 (нет device-id) — клиент сам инициализирует device-id и повторяет вызов.

Все клиенты собраны в один объект и провайдятся приложению:

const grpcInstance = {
  auth: createPromiseClient(Services.AuthService, transport),
  movie: createPromiseClient(Services.MovieService, transport),
  search: createPromiseClient(Services.SearchService, transport),
  recommendation: createPromiseClient(Services.RecommendationService, transport),
  catalog: createPromiseClient(Services.CatalogService, transport),
  /* ...всего 22 сервиса... */
}
return { provide: { grpcInstance } }

07 API-слой: типизированные обёртки + useGrpcQuery

Каждый эндпоинт — это маленький модуль в app/api/**, использующий сгенерированные из proto типы. Пример (главная страница):

// app/api/movie/main/get.ts
import { GetMainPageRequest } from '~/grpc/generated/content_pb.js'
import type { GetMainPageResponse } from '~/grpc/generated/content_pb.js'

export async function apiMovieMainGet() {
  return await useGrpcQuery<GetMainPageResponse>(async ({ clients }) => {
    const request = new GetMainPageRequest({})
    return await clients.movie.getMainPage(request)
  })
}

useGrpcQuery — единый адаптер ошибок: оборачивает вызов, конвертирует ответ через .toJSON(), и нормализует ошибку в дискриминированный union { data, error: null } | { data: null, error }. На SSR ConnectError приводится к строке (чтобы не утащить несериализуемый объект в гидрацию). Это аккуратная, типобезопасная обработка ошибок.

Использование на странице — через нативный useAsyncData Nuxt:

<script setup lang="ts">
import { apiMovieMainGet } from '~/api/movie/main/get'
const { data, isLoading } = await useAsyncData('main-page', () => apiMovieMainGet())
const sections = computed(() => data.value?.data?.sections || [])
</script>

08 «God»-компонент core/scope.vue

Чтобы не дублировать инициализацию между app.vue и error.vue, заведён единый scope.vue (DRY). Он держит глобальный loading-indicator, контейнер модалок и сквозную логику (поллинг Telegram-подписки, cookie-consent):

// components/core/scope.vue
watch(() => userStore.isAuthorized, handleModalIsTelegramSubscribed)
watch(() => userStore.isTelegramSubscribed, handleModalIsTelegramSubscribed)
onMounted(handleCookieConsent)

09 Кастомный Nuxt-модуль генерации иконок

modules/icon/index.ts — собственный Nuxt-модуль, который рекурсивно сканирует app/assets/icons/**.svg и генерирует TypeScript-тип с именами всех иконок:

addTypeTemplate({
  filename: 'types/ui-icon.d.ts',
  getContents: () => `export type TUiIconNames = "${iconKeys.join(' | ')}" | string`,
})

В результате <ui-icon name="..."> получает автодополнение и проверку имён на этапе компиляции. Это «meta-программирование» под себя — показатель того, что автор не просто пользуется фреймворком, а расширяет его.

10 Прочие сильные детали

  • Image proxy: все постеры идут через imgproxy с типобезопасным билдером URL (useImageProxy) — ресайз rs:fill:WxH, фолбэк на SVG-плейсхолдер.
  • Watch-сессии: useWatchSessionTracking хранит состояние в useState (переживает навигацию), нормализует «глухие» плееры (5 мин на странице = 30с просмотра), лимитирует 900с, шлёт пинги каждые 60с, синхронно сбрасывает состояние при unmount (защита от «грязных» сессий).
  • Фасетный каталог: тонкие страницы (/movies, /series, /genre/[slug], /studio/[slug]) — это обёртки над одним catalog-view с preset/locked и composable useCatalogFilters.
  • SSR-aware composables: useServerOnly, useClientOnly, useSSRWindowSize — аккуратная работа с границей сервер/клиент.
  • Material-набор UI: ui/input с variant outlined/filled, плавающим лейблом, leading/trailing-иконками, состояниями, интеграцией с vee-validate через path.

11 Pinia-сторы: состояние с пониманием SSR

7 сторов (app/stores/**, ~760 строк). Что в них примечательно — не объём, а аккуратность работы с границей сервер/клиент и стремление держать состояние производным, а не дублированным.

user.ts — состояние, выведенное из токена (а не скопированное)

Пользователь не хранится в сторе как отдельное поле — он каждый раз декодируется из JWT, который лежит в accessToken. Это исключает рассинхрон «данные в сторе ↔ реальный токен»:

const user = computed(() => {
  if (accessToken.value) return decodeJWT(accessToken.value)
  return null
})
const isAuthorized = computed(() => Boolean(accessToken.value))
const isAdmin = computed(() => ['AUTHOR', 'ADMIN', 'MODERATOR'].includes(user.value?.role))

Токены живут в useCookie (Nuxt сериализует cookie с сервера на клиент → авторизация переживает SSR без отдельного fetch). Геттер/сеттер computed синхронизирует ref ↔ cookie прозрачно:

const accessToken = computed({
  get: () => _accessToken.value,
  set: (value) => { _accessToken.value = value; accessTokenCookie.value = value },
})

Тонкая SSR-деталь — runWithContext вокруг навигации в watcher-е. Вне синхронного setup Nuxt теряет контекст инстанса; автор это знает и оборачивает редирект:

watch(isAuthorized, (value) => {
  if (value) nuxtApp.runWithContext(() => useBackTo().redirectAfterAuth())
})

Плюс защита import.meta.client на navigateTo в logout и retry-паттерн на устаревший client-id во всех трёх способах входа (email / sign-in / telegram): при ClientIdNotFound стор сбрасывает device-id и повторяет логин один раз.

dialog.ts — образцовая клиент-онли работа

Модалки — чисто клиентская сущность. Стор возвращает null на сервере целиком, не пытаясь инициализировать vue-final-modal в SSR:

export const useDialogStore = defineStore('dialogStore', () => {
  if (import.meta.server) return { modal: null }   // на сервере модалок нет
  // ...
  return { modal: skipHydrate({ search, addToPlaylist, share, /* ... */ }) }
})

Два сильных приёма:

  • skipHydrate — хэндлы модалок несериализуемы; без этого была бы ошибка гидрации. Автор точечно исключает их из гидрационного диффа.
  • defineAsyncComponent на каждой модалке — это code-splitting: код диалога грузится только при открытии, не утяжеляя первоначальный бандл.
  • Все модалки закрываются на смене роута (watch router.currentRoute).

device.ts / index.ts

  • device.ts — clientId в cookie, детект платформы через @nuxtjs/device.
  • index.ts (глобальный) — фазовая инициализация global / serverOnly / clientOnly, что парно работает с плагином 1.initialization.ts.

И мелочь, выдающая аккуратность: каждый стор подключает HMR (import.meta.hot.accept(acceptHMRUpdate(...))) — состояние не сбрасывается при правках в dev.

Ключевой раздел

12 SEO: собственный шаблонный движок с JSON-LD

Это самая сильная «продуктовая» часть фронта и прямое доказательство SEO-экспертизы. SEO не «прикручен метатегами в head» — это движок (useSeoModule, 269 строк + JSON-шаблоны на страницу + агрегаты).

Шаблоны на страницу + интерполяция переменных

Каждый тип страницы (home / watch / staff / catalog / static) описан JSON-шаблоном с плейсхолдерами %variable%. Движок резолвит их из локального и глобального контекста, экранируя кавычки и переводы строк (защита от поломки разметки):

template.replace(/%(.*?)%/g, (match, variable) => {
  let value = localContext[key] ?? context.value[key]
  if (typeof value === 'string') value = value.replace(/"/g, '&quot;').replace(/\n/g, ' ')
  return String(value)
})

schema.org JSON-LD (включая динамические списки)

Шаблон watch.json генерирует сразу три структуры schema.org: VideoObject/Movie или TVSeriesaggregateRating, director), BreadcrumbList и ItemList «Рекомендуем посмотреть». Последний собирается из массива похожих фильмов через агрегатные функции (movie_items_block):

{ "@type": "ItemList", "name": "Рекомендуем посмотреть",
  "itemListElement": "%movie_items_block%" }   // ← раскрывается в массив ListItem

На странице это применяется на этапе SSR (через watch(..., { immediate: true })), с подстановкой реальных данных фильма:

watch(movieData, (movie) => {
  const director = staffData.value?.find(s => s.professionKey === 'DIRECTOR')
  const { template, localContext } = seo.defineSeoPage('watch', {
    movie_name: movie.names?.find(n => n.language === 'RU')?.name ?? '',
    movie_rating: movie.rating?.kinopoisk ?? '0',
    movie_type: isSeries ? 'TVSeries' : 'Movie',
    /* ... */
  })
  if (relatedData.value?.length) {
    seo.generateCompositeContextByItems('movie_items_block', relatedData.value) // ItemList
  }
  seo.applySeo('watch', localContext)
}, { immediate: true })

Canonical, OG и пагинация для краулеров

applySeo автоматически проставляет <link rel="canonical">, OpenGraph (og:type/title/description/image/url/site_name), и — что часто забывают — rel="prev" / rel="next" для постраничных листингов:

state.value.useHead.link.push({ rel: 'canonical', href: canonicalUrl })
if (pageCurrent > 1) link.push({ rel: 'prev', href: prevUrl })
if (pageCurrent < pageTotal) link.push({ rel: 'next', href: nextUrl })

Состояние SEO живёт в useState → формируется на сервере и переносится в клиент без повторного вычисления.

Динамические sitemap’ы (server-side, из gRPC)

В server/routes/ — полноценный sitemap index с пагинированными картами для watch / genre / staff / playlist / static. Каждая карта генерируется Nitro-обработчиком, который сам ходит в backend по gRPC, отдаёт XML с правильным Content-Type, Cache-Control: max-age=3600 и корректным XML-экранированием:

const response = await sitemapClient.getSitemapWatch({ page })
const xml = generateSitemapXml(urls, config.public.BASE_URL)
event.node.res.setHeader('Content-Type', 'application/xml; charset=utf-8')
event.node.res.setHeader('Cache-Control', 'public, max-age=3600')

Плюс в head.config.ts — Yandex.Metrika (с защитой от двойной вставки скрипта), yandex-verification, noscript-пиксель. Это SEO уровня медиа-проекта, а не «сделал title динамическим».

Ключевой раздел

13 Понимание SSR (сквозные признаки)

SSR здесь не «включён галочкой» — он продуман на каждом слое, и это, пожалуй, главный технический акцент для frontend-инженера:

ПризнакГдеЧто показывает
Данные через useAsyncData со стабильными ключамиwatch/[kinopoiskId].vue (watch-kinopoiskId:${slug})дедупликация запросов и перенос payload на клиент без рефетча
Promise.all для параллельной загрузкиwatch-страница (5 запросов разом)минимизация TTFB на сервере
useTryAsync на каждый под-запросwatch-страницаизоляция ошибок: упавший «факты» не валит всю страницу
Нормализация ошибок на сервереuseGrpcQuery (ConnectError → string на import.meta.server)защита от несериализуемых объектов в гидрации
useState для SEO/трекингаuseSeoModule, useWatchSessionTrackingразделяемое сервер↔клиент состояние
skipHydrate / server-nulldialog.tsосознанный контроль гидрации несериализуемого
runWithContextuser.tsкорректный Nuxt-контекст вне синхронного setup
useServerOnly / useClientOnly / useSSRWindowSizecomposablesявные инструменты управления фазой исполнения
404 через useShowErrorNotFoundwatch-страницакорректный SSR-статус для несуществующего контента
// watch/[kinopoiskId].vue — SSR-корректный 404 (не «пустая страница 200»)
if (page.status.value === 'success' && !movieData.value?.kinopoiskId) {
  useShowErrorNotFound(() => true)
}

14 Оптимизации и внимание к деталям

  • Nuxt experimental включён осознанно: payloadExtraction, sharedPrerenderData, componentIslands, asyncContext, typedPages — всё работает на скорость загрузки и типобезопасность роутинга.
  • imgproxy на всех постерах — ресайз под точный размер карточки (240x320 и т.д.), типобезопасный билдер URL, SVG-плейсхолдеры при отсутствии изображения.
  • Code-splitting модалок через defineAsyncComponent — лёгкий первичный бандл.
  • Skeleton-состояния загрузки (главная рендерит каркас полок до приезда данных).
  • additionalData в Vite/SCSS — глобальные миксины/функции доступны во всех компонентах без ручного @use (DRY на уровне стилей).
  • Дедупликация JSON-LD по key (json-ld-${type}-${index}) — без дублей в head.
  • view-transition (useStartViewTransition) — плавные переходы между страницами.
  • Watch-трекинг с нормализацией «глухих» плееров и защитой от «грязных» сессий (см. §10) — внимание к корректности аналитических данных.

15 Система диалогов и UI-кит

Подход к диалогам — централизованный и type-safe

Модалки не разбросаны по компонентам, а собраны в единый реестр в dialog.ts store поверх vue-final-modal (vfm). Архитектура:

  • Движок (vfm) поднимается отдельным плагином vue-final-modal.client.ts.
  • Один глобальный контейнер ui/dialog/container.vue (рендерится в scope.vue).
  • 9 диалогов (search, select-player, add-to-playlist, share, telegram-subscribe, cookie-consent, catalog-filters, …), каждый — defineAsyncComponent → код модалки грузится только при открытии (code-splitting).
  • Типобезопасный API с атрибутами: addToPlaylist/share принимают attrs: { movie } — данные передаются в модалку через vfm, а не через глобальное состояние.
  • Server-null + skipHydrate (см. §11) — модалки не существуют на сервере и исключены из гидрации.
  • Авто-закрытие на смене роута (watch router.currentRoute) — нет «висящих» окон.

Открытие из любого места — через стор, без проп-дриллинга:

const dialogStore = useDialogStore()
dialogStore.modal?.addToPlaylist.open()        // ленивый компонент подгрузится сам
dialogStore.modal?.telegramSubscribe.close()

UI-кит — системный, не «набор кнопок»

В components/ui/25+ групп примитивов (button, input, select, autocomplete, chip-group, dropdown, tabs, catalog, filter, sort, view-mode, spinner, …). Признаки системности:

  • Material-набор инпутов (ui/input): variant outlined/filled, плавающий лейбл, leading/trailing-иконки, supporting text, счётчик, состояния hover/focus/disabled/readonly/error, типы text/email/password/number/search, и двойной режимpath + vee-validate ИЛИ обычный v-model.
  • Все цвета — токены тем (var(--theme-*)), хардкода нет.
  • Ширину задаёт родитель (внутри примитивов нет width: 100%) — осознанное правило композиции, делающее примитивы переиспользуемыми в любом контексте.
  • Переиспользуемый ui/catalog/view — один компонент каталога, из которого собраны страницы /movies, /series, /genre/[slug], /studio/[slug] через preset/locked (см. §16).

16 Composables: что стоит отметить

34 composable. Большинство — служебные адаптеры, но несколько демонстрируют сильный дизайн состояния.

useCatalogFilters — URL как единый источник истины

Лучший composable проекта. Состояние фасетного каталога живёт в query-строке URL, а не в сторе, — поэтому фильтры шарятся ссылкой, переживают перезагрузку и работают с SSR из коробки. Реализованы серьёзные паттерны:

  • Draft/commit: модалка фильтров правит черновик, apply() коммитит его в URL (router.replace), список реагирует на изменение query. Разделение «редактирую ↔ применил».
  • Preset/locked по странице: одна и та же фасетная вьюха используется на 6+ страницах; на /genre/[slug] секция «жанр» заблокирована и спрятана в модалке, на /studio/[slug] — «студия». Конфиг описывает, какие секции preset и locked:
const apply = (next: CatalogFiltersState) => {
  committed.value = forcePreset({ ...next }, preset, locked)  // locked секции форсятся из preset
  const filterQuery = toQuery(committed.value, locked)        // state → query (locked не попадают в URL)
  const query = { ...route.query, ...filterQuery }
  for (const [k, v] of Object.entries(filterQuery)) if (v === undefined) delete query[k]
  delete query[QUERY_PAGINATION_PARAM_NAME]                   // смена фильтра сбрасывает пагинацию
  router.replace({ name: route.name, params: route.params, query })
}
  • Чистые сериализаторы fromQuery/toQuery (csv↔массив, числа, дефолты не пишутся в URL → чистые ссылки), и countActiveFilters для бейджа активных фильтров.
  • Всё разделяемо через useState (SSR-safe).

useWatchSessionTracking — корректность аналитических данных

Уже упоминался (§10): useState для переживания навигации, нормализация «глухих» плееров, лимит 900с, пинги раз в 60с, синхронный сброс при unmount. Это composable, который думает о качестве данных, уходящих в ML.

Прочие, заслуживающие внимания

  • useGrpcQuery / useApiData — единый адаптер ошибок поверх gRPC (§7), с SSR-нормализацией ConnectError.
  • useImageProxy — типобезопасный билдер URL для imgproxy (§10).
  • useBreakpoint / useSSRWindowSize — SSR-безопасная работа с размерами вьюпорта.
  • useStartViewTransition — обёртка над View Transitions API.
  • useFavoriteManager, usePlayerjsControl, useSeoModule — инкапсуляция доменной логики (избранное, плеер, SEO) в переиспользуемые единицы.

И типизированная шина событий (2.eventBus.ts) — mitt с типизированной картой событий (movie:favorite:updated, app:search:value:updated) и wildcard-логированием только в dev. Плюс маркетинг-плагин с детектом источника трафика (UTM / telegram / vk / поисковики) и очисткой UTM из URL после захвата.

17 Оценка фронтенда (с поправкой: автор — frontend-разработчик)

Поскольку автор позиционирует себя как frontend-инженер, оценивать стоит строже — и фронт это выдерживает.

Сильные стороны (то, что подмечается сразу):

  • SSR-зрелость как система, а не набор хаков: cookie-перенос auth, useState, skipHydrate, server-null сторы, runWithContext, серверная нормализация ошибок, корректный 404. Это уровень человека, который понимает гидрацию, а не борется с ней.
  • SEO-движок промышленного уровня: шаблоны + JSON-LD (Movie/TVSeries, Breadcrumb, динамический ItemList), canonical/prev/next, OG, server-side sitemap index из gRPC, XML-экранирование, кэш-заголовки. Редко встречается даже в коммерческих SPA.
  • Состояние без дублирования (пользователь выводится из JWT) и аккуратные client-only сторы с code-splitting.
  • Фирменная liquid-вёрстка 1rem=1px, полная темизация на токенах, 7-1 SCSS, BEM.
  • Зрелый auth/refresh (single-flight, WeakMap-ретраи), расширение фреймворка под себя (icon-модуль, авто-импорт API), сквозная типобезопасность от proto до шаблона.
  • Внимание к деталям: дедуп JSON-LD, изоляция ошибок под-запросов, skeleton-состояния, view-transitions, кэш-заголовки на sitemap.
  • Системный UI-кит и централизованные диалоги (реестр + lazy-компоненты + type-safe attrs), а не «набор кнопок».
  • useCatalogFilters — URL как источник истины, draft/commit, preset/locked на страницу: одна фасетная вьюха на 6+ страниц. Зрелый дизайн shareable-состояния.

Замечания:

  • PWA-конфиг закомментирован (@vite-pwa/nuxt отключён) — незавершённая ветка.
  • Часть сторов/composables/SEO-движок объёмны; есть простор для дробления и юнит-тестов (особенно интерполяция/JSON-LD — идеальные кандидаты на покрытие).
  • Stories/Chromatic заявлены в CONTRIBUTING, но визуальное тестирование, судя по всему, не настроено сквозняком.
  • В head.config.ts инлайн-скрипт Метрики — приемлемо, но просится в отдельный client-плагин для чистоты.

Уровень frontend-исполнителя — уверенный Senior. Ключевые аргументы именно для фронтендера: глубокое, систематическое владение SSR/гидрацией; SEO-инфраструктура медиа-уровня с JSON-LD и серверными sitemap’ами; состояние, спроектированное на производность и client/server-корректность; и собственные абстракции над фреймворком.