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

и какой стал

было

и стало

Комментарии (12)

CoolCmd
21.05.2026 14:48Самое заметное решение. Раньше у меня на всех страницах был один статичный фиолетовый акцент
что-то я не вижу акцента из постера. заблюренный постер в фоне есть, акцента - нет.
какими приёмами вы «удешевляете» или, наоборот, «удорожаете» интерфейс малой кровью?
я скажу о статье, а не о сайте, можно? у “дорогой” статьи в том числе должен быть правильно оформлен исходный код. у вас же какой-то трындец, в первый раз такое вижу.

vibemuvik Автор
21.05.2026 14:48у каждого свой акцент /, у каждого свое виденье ,изначальный шаблон дизайн был обычным стандартным для многих cms ,второй шаблон уже выглядит немного интересней профессиональней ,и я вас удивлю можете в яндекс в бить запрос кино фильмы и первую страницу выдачи пробежаться по таким сайтам у них у всех одинаковый похожий с моим стилем,я до этого смотрел что как такие же сайты делал похожей .все друг у друга подсматривают идеи. вы написали мой исходный код трындец какой то ,в чем трендец не понятно объясните не соловыми а конкретным чем код такой. это сайт и весь код движок для него админка сайт страницы уникальны все с нуля написано.я не писал сайт на популярных движках и не кричу что сайт сделал. все сделано с нуля .да код еще не совсем чисты есть и мертвый код так называемый я делаю чище .но извините я это сделал за пару месяцев .и функционал движка админки позавидуют популярные cms и это мое мнение .Спасибо и вы написали видите первый раз такое видите значит он уже уникальный хотя бы и негативным отзывом.)

CoolCmd
21.05.2026 14:48у каждого свой акцент /, у каждого свое виденье
я задал конкретный вопрос. в статье “самым заметным решением” объявляется цвет, рассчитываемый как усредненный цвет постера. где этот цвет, как он используется? я открыл два фильма и они различаются лишь заблюренным фоном.
в чем трендец не понятно объясните не соловыми а конкретным чем код такой
в статье каждый блок кода вставлен в двух экземплярах, и оба оформлены криво.

vibemuvik Автор
21.05.2026 14:48Цвет правда вычисляется и он разный у разных фильмов. Алгоритм: постер сжимается в canvas 32×48, усредняются только насыщенные пиксели (near-black/white и серые отбрасываются), результат → HSL с бустом насыщенности, кладётся в CSS-переменную
--movie-accent. Проверить просто: в DevTools на<html>будет, например, у «Зелёной мили»hsl(28 85% 52%)(тёплый оранжевый), а у другого фильма —hsl(256 ...)(фиолетовый).Где используется (movie-evolution.css): свечение и тень под постером, радиальный glow в hero, градиент и тень кнопки действия, бордеры акцентных блоков.
Но по существу ты прав: эффект я сделал слишком деликатным (glow с альфой 0.18), и на фоне яркого blur-backdrop его легко не заметить — тем более если у двух постеров близкая палитра. Называть это «самым заметным решением» — мой перебор в тексте. Уже усиливаю: вывожу акцент на рамку/свечение постера и рейтинг заметнее, чтобы разница читалась сразу.
Про дублирование блоков кода и кривое форматирование — спасибо, это я накосячил с разметкой статьи, поправлю.

vibemuvik Автор
21.05.2026 14:48спасибо за ,сегодня займусь чисткой кода. а правильное оформление ну у всех может быть по разному или же наоборот шаблонно одинаковые. Я как видел так и писал ,сегодня займусь кодом посмотрю что можно улучшить ,как у меня тут как то спрашивали зачем пишу статьи да хотя бы за тем что бы послушать умных людей более опытных и исправить что то ,совет или критику услышать ,и чему то научится у людей с большим опытом, Спасибо за честный комментарий.

Crusader_12
21.05.2026 14:48"и функционал движка админки позавидуют популярные cms и это мое мнение " Ой че и в правду так все кучеряво)? Может похвастаетесь, любопытно хотя бы какое то сравнение увидеть и лучше со скринами, просто уже приелись такие заявления , тут уже не первая статья про то как в одиночку свои CMS пилят, которые уделывают проекты которые целые сообщества годами разрабатывают и не могут таких чудес сотворить. Как то слабо верится в такое.

vibemuvik Автор
21.05.2026 14:48я обещаю что вот начну скоро со скриншотами и кодом выкладывать статьи как я это делал .ну я не говорил что моя cms уделывает кого то .а функции которым позавидуют.
milinsky
А в чём сакральный смысл существования проекта? Таких сайтов сотни, и все на одну волну, разве что внешне отличаются. Совместные просмотр с фильмопомоек? Так это уже давно много где есть. Зачем мне ваш сервис? Какую проблему он решает для пользователя?
vibemuvik Автор
Вопрос честный, без обид — отвечу по делу.
Да, каталогов с рейтингами и рецензиями вагон, и «ещё один Кинопоиск» я делать не пытаюсь. VibeMuvik закрывает не «где глянуть оценку фильма», а две другие боли.
Первая — «что посмотреть именно мне». Не общий топ по популярности (он у всех одинаковый), а рекомендации от Cinema DNA: вектор вкуса из 24 измерений — не жанры, а темп, тон, сложность, визуальный стиль, эмоциональная палитра. Та же механика считает киносовместимость с другими людьми — насколько ваши вкусы совпадают и в чём расходятся. У Кинопоиска «сравнить вкусы» завезли только в 2026-м, у меня это ядро с запуска.
Вторая — «не с кем смотреть и обсуждать». Вот здесь КиноПати, и это не то, что вы подумали. Это не плеер поверх «помойки», а синхронный сеанс на WebRTC: расшариваешь свой локальный файл или экран — у всех в комнате картинка идёт секунда-в-секунду, плюс голосовой чат. Смотрим вместе то, что есть у нас, а не то, что лежит в чьём-то каталоге. Ближайший аналог — watch party в Discord, только заточено под кино и без плясок с бубном. А вокруг — обычная соцсеть: рецензии, списки, клубы, обсуждения.
Кому не нужно — скажу честно: если вам хватает глянуть рейтинг и закрыть вкладку, я ничего нового не дам. Проект для тех, кто относится к кино как к хобби: ведёт дневник, ищет «своё», смотрит и обсуждает с людьми. Ниша узкая, не спорю. Но именно в такой связке — вкусовые рекомендации + совместный просмотр своих файлов + соцсеть на русском — «сотен таких» я не встречал. Подскажете аналоги — без иронии гляну, как сделано у других.
milinsky
Сервисов по подбору по настроению, темпу и прочему куча, они появляются и исчезают как ненужный вид. Это крайне сомнительная проблема для людей.
Сервисов совместного просмотра по ссылке или файлу - куча, есть даже с автопоиском по этим самым помойкам и сами подставляют ссылки. Они появляются и исчезают, так как явление когнитивно воспринимаемое далеко не всеми. Но люди по-прежнему создают одно и тоже. С гороскопами и без, с анкетами и прочим. Вайбкодеры так вообще клепают такие сервисы каждый день. И все они уходят в небытие.
Отсюда и был вопрос - зачем? Это давно опробовано многими, особенно вайбкодерами.
Я помню на Нетфликс пытались сделать то же самое, и по итогу отказались от этой идеи.
Вы поймите, я не критикую ваш проект, я пытаюсь понять, что движет людьми что они “придумывают” одинаковые идеи раз разом и считают своё решение уникальным? Я уже молчу про всякие фитнес приложения, которых миллионы, фудсервисы которых миллионы. Появляются и исчезают.
Тем не менее, желаю вам успехов. Кто знает, может и взлетит хоть ненадолго.
vibemuvik Автор
я спокойно воспринимаю ваши критику, я в чем то свами согласен ,вы посмотрите сколько примеров появляется таких подобных и залетаю намного проще,и мониторя эту нищу очень много есть подобные проекты так и не взлетают но находят свою нищу и свих пользователе ,а конкурировать в топ 1 по выдачи с 0 руб естественно не получится. но кто знает
Yarus23
Молодец что сделали, наверное много чему научились. Но в эру вайбкодинга вход в такие продукты сильно снизился и вопрос уже не в том КАК сделать а в том КАК продать и как обеспечить организационные моменты - команда, реклама, надежность. А это все стоит денег увы.