01 Технологическое ядро
| Категория | Выбор |
|---|---|
| Мета-фреймворк | Nuxt 4.3 (compatibilityVersion: 4), SSR-режим |
| UI | Vue 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):
Граница между 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), а верстальщик пишет ровно те же числа в rem — font-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 refresh —
createRefreshManagerдедуплицирует параллельные 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и composableuseCatalogFilters. - 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, '"').replace(/\n/g, ' ')
return String(value)
})
schema.org JSON-LD (включая динамические списки)
Шаблон watch.json генерирует сразу три структуры schema.org: VideoObject/Movie или TVSeries (с aggregateRating, 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-null | dialog.ts | осознанный контроль гидрации несериализуемого |
runWithContext | user.ts | корректный Nuxt-контекст вне синхронного setup |
useServerOnly / useClientOnly / useSSRWindowSize | composables | явные инструменты управления фазой исполнения |
404 через useShowErrorNotFound | watch-страница | корректный 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-корректность; и собственные абстракции над фреймворком.