Чтобы не выглядело как пет-проект»: как я в одиночку сделал премиальный интерфейс кино-сервиса (с кодом)

от автора

В прошлой статье я рассказывал, каково в одиночку тащить фуллстек-проект, который разросся до кино-соцсети. В комментариях несколько раз спросили про конкретику — «покажи код», «как сделал, что не выглядит как очередной пет-проект». Логично: дизайн — это то, по чему встречают. Поэтому держите вторую часть, уже техническую и с кодом. Без маркетинга, только решения, которые реально сделали интерфейс «дорогим», и пара бэкенд-хитростей в довесок.

Сразу дисклеймер: я не дизайнер. Всё нажито методом «смотрю на референсы (Letterboxd, Mubi, KinoPoisk HD) и пытаюсь повторить ощущение». Оказалось, премиальность — это не про дорогие шрифты, а про несколько повторяющихся приёмов. Разберём пять.

1. Акцентный цвет из постера фильма — фича, которая дороже всего «продаёт»

Самое заметное решение. Раньше у меня на всех страницах был один статичный фиолетовый акцент — и это выглядело дёшево и одинаково. Идея: пусть каждая страница фильма подсвечивается доминантным цветом его постера. Заходишь на мрачный нуар — интерфейс уходит в холодный синий, открываешь комедию — тёплый янтарь. Страница будто «сделана под этот фильм».

Делается без всяких ML, прямо в браузере через canvas: рисуем постер в крошечный буфер 32×48, усредняем цвета (выкидывая чёрные рамки и серость), переводим в HSL и принудительно «насыщаем», потому что постеры часто тусклые. Результат кладём в CSS-переменную — и весь интерфейс подхватывает её.

function applyPosterAccent(posterUrl: string) {  const img = new Image();  img.crossOrigin = "anonymous";  img.onload = () => {    const canvas = document.createElement("canvas");    const w = (canvas.width = 32), h = (canvas.height = 48);    const ctx = canvas.getContext("2d");    if (!ctx) return;    ctx.drawImage(img, 0, 0, w, h);    const { data } = ctx.getImageData(0, 0, w, h);    let r = 0, g = 0, b = 0, n = 0;    for (let i = 0; i < data.length; i += 4) {      const R = data[i], G = data[i + 1], B = data[i + 2];      const max = Math.max(R, G, B), min = Math.min(R, G, B);      if (max < 30 || min > 230) continue; // чёрные рамки / выбеленные пиксели      if (max - min < 25) continue;          // серое — в акцент не годится      r += R; g += G; b += B; n++;    }    if (!n) return;    const { h: hue, s, l } = rgbToHsl(r / n, g / n, b / n);    // постеры часто тусклые — принудительно делаем цвет «сочным»    const sat = Math.min(0.85, Math.max(0.45, s * 1.6 + 0.15));    const lit = Math.min(0.62, Math.max(0.52, l));    document.documentElement.style.setProperty(      "--movie-accent", `hsl(${hue | 0} ${(sat * 100) | 0}% ${(lit * 100) | 0}%)`    );    document.documentElement.style.setProperty(      "--movie-accent-soft", `hsl(${hue | 0} ${(sat * 100) | 0}% ${(lit * 100) | 0}% / .18)`    );  };  img.src = posterUrl;}

const img = new Image();

img.crossOrigin = "anonymous";

img.onload = () => {

const canvas = document.createElement("canvas");

const w = (canvas.width = 32), h = (canvas.height = 48);

const ctx = canvas.getContext("2d");

if (!ctx) return;

ctx.drawImage(img, 0, 0, w, h);

const { data } = ctx.getImageData(0, 0, w, h);

let r = 0, g = 0, b = 0, n = 0;

for (let i = 0; i < data.length; i += 4) {

const R = data[i], G = data[i + 1], B = data[i + 2];

const max = Math.max(R, G, B), min = Math.min(R, G, B);

if (max < 30 || min > 230) continue; // чёрные рамки / выбеленные пиксели

if (max - min < 25) continue; // серое — в акцент не годится

r += R; g += G; b += B; n++;

}

if (!n) return;

const { h: hue, s, l } = rgbToHsl(r / n, g / n, b / n);

// постеры часто тусклые — принудительно делаем цвет «сочным»

const sat = Math.min(0.85, Math.max(0.45, s * 1.6 + 0.15));

const lit = Math.min(0.62, Math.max(0.52, l));

document.documentElement.style.setProperty(

"--movie-accent", hsl(${hue | 0} ${(sat 100) | 0}% ${(lit 100) | 0}%)

);

document.documentElement.style.setProperty(

"--movie-accent-soft", hsl(${hue | 0} ${(sat 100) | 0}% ${(lit 100) | 0}% / .18)

);

};

img.src = posterUrl;

}

rgbToHsl — обычная конверсия RGB→HSL, она в любом сниппете в сети. Важны два момента: фильтрация «мусорных» пикселей (без неё на чёрных постерах акцент получается грязно-серым) и буст насыщенности (s * 1.6 + 0.15) — без него половина фильмов давала бы блёклый акцент.

Стоит это копейки по производительности (даунскейл до 32×48 — это меньше 1500 пикселей), а ощущение даёт именно «премиальное»: интерфейс реагирует на контент.

2. Glassmorphism через одну переменную, а не 100 захардкоженных цветов

Второй приём — стекло. Но не «прилепил blur и забыл», а так, чтобы всё было завязано на тот же --movie-accent. Тогда стеклянные карточки, свечения и обводки автоматически окрашиваются под фильм, и это выглядит цельно.

.glass-card {  background: linear-gradient(180deg,      var(--movie-accent-soft, rgba(255,255,255,.06)),      rgba(255,255,255,.02));  backdrop-filter: blur(18px) saturate(1.2);  border: 1px solid rgba(255,255,255,.08);  border-radius: 18px;  box-shadow: 0 24px 60px -20px var(--movie-accent-soft);}

background: linear-gradient(180deg,

var(--movie-accent-soft, rgba(255,255,255,.06)),

rgba(255,255,255,.02));

backdrop-filter: blur(18px) saturate(1.2);

border: 1px solid rgba(255,255,255,.08);

border-radius: 18px;

box-shadow: 0 24px 60px -20px var(--movie-accent-soft);

}

Главный урок: премиальность = единый источник правды для темы. Когда цвета разбросаны по компонентам, всё рассыпается на «почти одинаковые» оттенки и выглядит самодельно. Когда есть 5–6 CSS-переменных (--bg--glass--accent--accent-soft--text--dim) — всё дышит в унисон, и тему можно крутить в одну строку.

Отдельная боль с SSR: стеклянные слои на тёмном фоне дают мерзкий «мигающий» FOUC, если стили приедут позже разметки. Лечится инлайном критических переменных прямо в <head> на сервере — тогда первый кадр уже правильный.

3. Анимированная «кинолента» сверху — мелочь, которая считывается как «дорого»

Под шапкой у меня едет горизонтальная лента постеров — то, что подсознательно ассоциируется со стриминг-сервисами. Сама по себе она простая (горизонтальный скролл + стрелки), но есть нюанс UX, который отличает «ленту» от «дёшево прибитого ряда картинок»: стрелки должны гаснуть в крайних положениях.

const updateArrows = () => {  const el = stripRef.current;  if (!el) return;  setCanLeft(el.scrollLeft > 4);  setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);};// вешаем на el.addEventListener("scroll", updateArrows, { passive: true })// + ResizeObserver, чтобы пересчитывать при ресайзе

const el = stripRef.current;

if (!el) return;

setCanLeft(el.scrollLeft > 4);

setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);

};

// вешаем на el.addEventListener("scroll", updateArrows, { passive: true })

// + ResizeObserver, чтобы пересчитывать при ресайзе

Плюс первые 4 постера грузим loading="eager" (они выше fold и влияют на LCP), остальные — lazy. Звучит банально, но именно из таких мелочей складывается ощущение, что «всё плавно и продумано».

4. Web Push без Firebase — на чистом VAPID

Хватит про фронт. Пуш-уведомления я сделал на нативном Web Push, без FCM и сторонних сервисов — не хотелось вендор-лока ради пет-проекта. На фронте регистрируем service worker и подписываемся:

const reg = await navigator.serviceWorker.register("/sw.js");const sub = await reg.pushManager.subscribe({  userVisibleOnly: true,  applicationServerKey: urlB64ToUint8Array(VAPID_PUBLIC_KEY),});await fetch("/api/push/subscribe", {  method: "POST",  headers: { "Content-Type": "application/json" },  body: JSON.stringify(sub),});

const sub = await reg.pushManager.subscribe({

userVisibleOnly: true,

applicationServerKey: urlB64ToUint8Array(VAPID_PUBLIC_KEY),

});

await fetch("/api/push/subscribe", {

method: "POST",

headers: { "Content-Type": "application/json" },

body: JSON.stringify(sub),

});

Подписку (endpoint + ключи p256dh/auth) храню в PostgreSQL, по одной на устройство. Отправка на бэке (FastAPI) — через pywebpush:

from pywebpush import webpush, WebPushExceptiondef send_push(sub, title, body, url="/"):    try:        webpush(            subscription_info={                "endpoint": sub.endpoint,                "keys": {"p256dh": sub.p256dh, "auth": sub.auth},            },            data=json.dumps({"title": title, "body": body, "url": url}),            vapid_private_key=VAPID_PRIVATE,            vapid_claims={"sub": "mailto:admin@example.com"},            ttl=3600,        )    except WebPushException as e:        # 404/410 = подписка протухла → удаляем из базы, чтобы не копился мусор        if e.response is not None and e.response.status_code in (404, 410):            delete_subscription(sub.id)

def send_push(sub, title, body, url="/"):

try:

webpush(

subscription_info={

"endpoint": sub.endpoint,

"keys": {"p256dh": sub.p256dh, "auth": sub.auth},

},

data=json.dumps({"title": title, "body": body, "url": url}),

vapid_private_key=VAPID_PRIVATE,

vapid_claims={"sub": "mailto:admin@example.com"},

ttl=3600,

)

except WebPushException as e:

# 404/410 = подписка протухла → удаляем из базы, чтобы не копился мусор

if e.response is not None and e.response.status_code in (404, 410):

delete_subscription(sub.id)

Грабли, на которых стоит сэкономить вам нервы: на iOS пуши прилетают только если сайт установлен как PWA (Safari 16.4+) — это ограничение Apple, не ваше. И обязательно чистите мёртвые подписки по 404/410, иначе таблица распухает «фантомными» устройствами.

5. Один честный рейтинг из голосов и рецензий (и баг, который я этим закрыл)

Бэкенд-история, которую Хабр любит: «было криво — стало красиво». У меня есть два способа оценить фильм — звёздный голос («оценить») и оценка внутри рецензии. Сначала они жили раздельно: один обработчик считал рейтинг из таблицы голосов, другой — из рецензий, и оба писали в одно и то же поле. Итог — рейтинг скакал в зависимости от того, что произошло последним. Классическая «неразбериха».

Починил одним SQL — единый рейтинг как среднее по уникальным пользователям, где у каждого берётся его звёздный голос, а если голоса нет — оценка из одобренной рецензии:

WITH per_user AS (    SELECT user_id, rating FROM movie_votes WHERE movie_id = :mid    UNION ALL    SELECT user_id, rating FROM reviews      WHERE movie_id = :mid        AND status = 'approved'        AND user_id NOT IN (SELECT user_id FROM movie_votes WHERE movie_id = :mid))SELECT COALESCE(AVG(rating), 0), COUNT(*) FROM per_user;

SELECT user_id, rating FROM movie_votes WHERE movie_id = :mid

UNION ALL

SELECT user_id, rating FROM reviews

WHERE movie_id = :mid

AND status = 'approved'

AND user_id NOT IN (SELECT user_id FROM movie_votes WHERE movie_id = :mid)

)

SELECT COALESCE(AVG(rating), 0), COUNT(*) FROM per_user;

NOT IN (...голоса...) — это и есть дедупликация: проголосовал и написал рецензию — учтём один раз, голос приоритетнее. Эту функцию я зову из всех мест, где рейтинг пересчитывается (голос, создание/правка/удаление рецензии). Один источник правды — и поле перестало «дышать».

Бонус: возрастной гейт, который не убивает SEO

Раз уж про кино — нужен был блок 18+ на «взрослых» фильмах. Тут легко выстрелить себе в ногу: если рисовать гейт на сервере и прятать под ним контент, поисковик увидит заглушку вместо страницы и выкинет её из индекса. Поэтому гейт у меня — строго клиентский оверлей поверх уже отрендеренного контента:

"use client";export default function AgeGate({ ageRating, mpaa }: Props) {  const [confirmed, setConfirmed] = useState(false);  useEffect(() => {    setConfirmed(localStorage.getItem("vm_age18_ok") === "1");  }, []);  if (!is18Plus(ageRating, mpaa) || confirmed) return null;  return <div className="age-overlay">/* … */</div>;}

export default function AgeGate({ ageRating, mpaa }: Props) {

const [confirmed, setConfirmed] = useState(false);

useEffect(() => {

setConfirmed(localStorage.getItem("vm_age18_ok") === "1");

}, []);

if (!is18Plus(ageRating, mpaa) || confirmed) return null;

return <div className="age-overlay">/* … */</div>;

}

Поисковый робот получает полный серверный HTML со всем контентом фильма; гейта в этой HTML нет (он дорисовывается в браузере по localStorage), редиректов и noindex тоже нет. Для живого человека контент перекрыт, для индексации — как будто гейта не существует. Возрастные проверки, к слову, прямо исключены из «штрафа за навязчивые баннеры» у Google.

Что в итоге

Премиальность в одиночку — это не про бюджет, а про несколько дисциплинированных приёмов: реакция интерфейса на контент (цвет из постера), единая тема через переменные, внимание к микро-UX (гаснущие стрелки, приоритет загрузки), и аккуратные «скучные» бэкенд-решения, которые не подводят. Ничего из этого не требует команды — только насмотренности и готовности переделать раз пять.

Если интересно потыкать живьём, на чём всё это крутится в бою — проект открыт: vibemuvik.ru. Не зову регистрироваться, просто если по ходу статьи стало любопытно, как «цвет из постера» и стекло выглядят вместе — можно зайти и посмотреть на странице любого фильма.

А вам интересно: какими приёмами вы «удешевляете» или, наоборот, «удорожаете» интерфейс малой кровью? И где, по-вашему, граница между «премиально» и «перегружено эффектами»? Особенно любопытно от тех, кто тоже тащит фронт в одиночку — поделитесь своими находками в комментариях.

и какой стал

было

и стало

ссылка на оригинал статьи https://habr.com/ru/articles/1037818/