В прошлой статье я рассказывал, каково в одиночку тащить фуллстек-проект, который разросся до кино-соцсети. В комментариях несколько раз спросили про конкретику — «покажи код», «как сделал, что не выглядит как очередной пет-проект». Логично: дизайн — это то, по чему встречают. Поэтому держите вторую часть, уже техническую и с кодом. Без маркетинга, только решения, которые реально сделали интерфейс «дорогим», и пара бэкенд-хитростей в довесок.
Сразу дисклеймер: я не дизайнер. Всё нажито методом «смотрю на референсы (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/