惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

GbyAI
GbyAI
N
News and Events Feed by Topic
D
DataBreaches.Net
MongoDB | Blog
MongoDB | Blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Engineering at Meta
Engineering at Meta
T
Tailwind CSS Blog
博客园_首页
Microsoft Azure Blog
Microsoft Azure Blog
Y
Y Combinator Blog
博客园 - Franky
Hugging Face - Blog
Hugging Face - Blog
月光博客
月光博客
A
About on SuperTechFans
I
InfoQ
S
Securelist
Last Week in AI
Last Week in AI
S
Schneier on Security
C
CXSECURITY Database RSS Feed - CXSecurity.com
Hacker News: Ask HN
Hacker News: Ask HN
Schneier on Security
Schneier on Security
Know Your Adversary
Know Your Adversary
腾讯CDC
大猫的无限游戏
大猫的无限游戏
S
Security @ Cisco Blogs
博客园 - 三生石上(FineUI控件)
Simon Willison's Weblog
Simon Willison's Weblog
D
Darknet – Hacking Tools, Hacker News & Cyber Security
T
Tor Project blog
美团技术团队
aimingoo的专栏
aimingoo的专栏
G
Google Developers Blog
罗磊的独立博客
Vercel News
Vercel News
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
The Cloudflare Blog
S
Secure Thoughts
www.infosecurity-magazine.com
www.infosecurity-magazine.com
Latest news
Latest news
Recent Announcements
Recent Announcements
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
L
LINUX DO - 热门话题
Security Latest
Security Latest
TaoSecurity Blog
TaoSecurity Blog
Cyberwarzone
Cyberwarzone
有赞技术团队
有赞技术团队

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Чтобы не выглядело как пет-проект»: как я в одиночку сделал премиальный интерфейс кино-сервиса (с кодом)
vibemuvik · 2026-05-21 · via Все публикации подряд на Хабре

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

Время на прочтение8 мин

Охват и читатели168

Кейс

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

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

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

и какой стал

было

и стало