feat: match youtube homepage layout
This commit is contained in:
1 parent
e29fb4c859
commit
47438186af
7 files changed
+172
-44
No files matched your search
@@ -1,8 +1,8 @@
|
||||
// Author: fruit.com.cn
|
||||
import { Play } from 'lucide-react';
|
||||
import { MoreVertical, Play } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function MediaCard({ video, compact = false }) {
|
||||
const image = video.image?.startsWith('/') || video.image?.startsWith('http') ? video.image : `/assets/${video.image}`;
|
||||
return <article className={`media-card ${compact ? 'compact' : ''}`}><Link to={`/watch/${video.id}`} className="media-thumb"><img src={image} alt={video.title} loading="lazy" /><b>{video.duration}</b><span className="thumb-play"><Play size={16} fill="currentColor" /></span></Link><div className="media-meta"><span className="channel-avatar">{video.channel?.[0] || 'Y'}</span><div><Link to={`/watch/${video.id}`} className="media-title">{video.title}</Link><small>{video.channel}<br />{video.views} · 2 hari lalu</small></div></div></article>;
|
||||
return <article className={`media-card ${compact ? 'compact' : ''}`}><Link to={`/watch/${video.id}`} className="media-thumb"><img src={image} alt={video.title} loading="lazy" /><b>{video.duration}</b><span className="thumb-play"><Play size={16} fill="currentColor" /></span></Link>{compact ? <Link to={`/watch/${video.id}`} className="short-media-title">{video.title}</Link> : <div className="media-meta"><span className="channel-avatar">{video.channel?.[0] || 'Y'}</span><div className="media-copy"><Link to={`/watch/${video.id}`} className="media-title">{video.title}</Link><small>{video.channel}<br />{video.views} · 2 hari lalu</small></div><button className="media-more" type="button" aria-label="More actions"><MoreVertical size={18} /></button></div>}</article>;
|
||||
}
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
export const locales = {
|
||||
id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', home: 'Beranda', shorts: 'Shorts', subscriptions: 'Langganan', library: 'Perpustakaan', history: 'Riwayat', liked: 'Drama disukai', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shortTitle: 'Cerita dalam satu tarikan napas', viewAll: 'Lihat semua →', explore: 'Jelajahi →', videoCount: 'video', channels: 'Ikuti cerita favoritmu', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'], follow: 'Ikuti', following: 'Mengikuti', empty: 'Belum ada drama yang cocok.', loading: 'Memuat dari server…', apiFallback: 'Server belum tersedia, menampilkan katalog demo sementara.', playbackUnavailable: 'Pilih episode untuk mulai menonton.', swipeHint: 'Geser ke atas untuk menemukan cerita berikutnya' },
|
||||
en: { code: 'en', htmlLang: 'en', title: 'YMB · Short stories, big emotions', description: 'Discover the best short dramas and new episodes every day.', search: 'Search dramas, actors, or genres', home: 'Home', shorts: 'Shorts', subscriptions: 'Subscriptions', library: 'Library', history: 'History', liked: 'Liked dramas', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shortTitle: 'Stories in one breath', viewAll: 'View all →', explore: 'Explore →', videoCount: 'videos', channels: 'Follow your favorite stories', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets', 'Second chance'], follow: 'Follow', following: 'Following', empty: 'No dramas match your search.', loading: 'Loading from server…', apiFallback: 'Server unavailable, showing the demo catalog temporarily.', playbackUnavailable: 'Select an episode to start watching.', swipeHint: 'Swipe up to discover the next story' },
|
||||
zh: { code: 'zh', htmlLang: 'zh-CN', title: 'YMB · 短故事,大情绪', description: '发现精彩短剧,每天更新新剧集。', search: '搜索短剧、演员或题材', home: '首页', shorts: 'Shorts', subscriptions: '订阅', library: '媒体库', history: '观看记录', liked: '喜欢的短剧', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shortTitle: '一口气看完的故事', viewAll: '查看全部 →', explore: '探索 →', videoCount: '个视频', channels: '关注你喜欢的故事', genres: ['全部', '爱情', '复仇', '家庭', '秘密', '重生'], follow: '关注', following: '已关注', empty: '没有找到匹配的短剧。', loading: '正在从服务端加载…', apiFallback: '服务端暂不可用,暂时展示演示目录。', playbackUnavailable: '请选择剧集开始播放。', swipeHint: '向上滑动发现下一个故事' }
|
||||
id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', voiceSearch: 'Cari dengan suara', create: 'Buat', notifications: 'Notifikasi', account: 'Akun', home: 'Beranda', shorts: 'Shorts', subscriptions: 'Langganan', library: 'Perpustakaan', history: 'Riwayat', you: 'Anda', playlists: 'Playlist', watchLater: 'Tonton nanti', likedVideos: 'Video yang disukai', myVideos: 'Video saya', explore: 'Jelajahi', music: 'Musik', movies: 'Film', live: 'Live', more: 'Lainnya', liked: 'Drama disukai', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shortTitle: 'Shorts', viewAll: 'Lihat semua →', videoCount: 'video', channels: 'Ikuti cerita favoritmu', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'], homeCategories: ['Semua', 'Podcast', 'Musik', 'Game', 'Live', 'Travel', 'Baru diupload', 'Temukan video baru'], follow: 'Ikuti', following: 'Mengikuti', empty: 'Belum ada drama yang cocok.', loading: 'Memuat dari server…', apiFallback: 'Server belum tersedia, menampilkan katalog demo sementara.', playbackUnavailable: 'Pilih episode untuk mulai menonton.', swipeHint: 'Geser ke atas untuk menemukan cerita berikutnya' },
|
||||
en: { code: 'en', htmlLang: 'en', title: 'YMB · Short stories, big emotions', description: 'Discover the best short dramas and new episodes every day.', search: 'Search dramas, actors, or genres', voiceSearch: 'Search with your voice', create: 'Create', notifications: 'Notifications', account: 'Account', home: 'Home', shorts: 'Shorts', subscriptions: 'Subscriptions', library: 'Library', history: 'History', you: 'You', playlists: 'Playlists', watchLater: 'Watch later', likedVideos: 'Liked videos', myVideos: 'Your videos', explore: 'Explore', music: 'Music', movies: 'Movies', live: 'Live', more: 'Show more', liked: 'Liked dramas', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shortTitle: 'Shorts', viewAll: 'View all →', videoCount: 'videos', channels: 'Follow your favorite stories', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets', 'Second chance'], homeCategories: ['All', 'Podcasts', 'Music', 'Gaming', 'Live', 'Travel', 'Recently uploaded', 'New to you'], follow: 'Follow', following: 'Following', empty: 'No dramas match your search.', loading: 'Loading from server…', apiFallback: 'Server unavailable, showing the demo catalog temporarily.', playbackUnavailable: 'Select an episode to start watching.', swipeHint: 'Swipe up to discover the next story' },
|
||||
zh: { code: 'zh', htmlLang: 'zh-CN', title: 'YMB · 短故事,大情绪', description: '发现精彩短剧,每天更新新剧集。', search: '搜索短剧、演员或题材', voiceSearch: '通过语音搜索内容', create: '创建', notifications: '通知', account: '账号菜单', home: '首页', shorts: 'Shorts', subscriptions: '订阅', library: '媒体库', history: '观看记录', you: '我', playlists: '播放列表', watchLater: '稍后观看', likedVideos: '赞过的视频', myVideos: '我的视频', explore: '探索', music: '音乐', movies: '电影', live: '直播', more: '展开', liked: '喜欢的短剧', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shortTitle: 'Shorts', viewAll: '查看全部 →', videoCount: '个视频', channels: '关注你喜欢的故事', genres: ['全部', '爱情', '复仇', '家庭', '秘密', '重生'], homeCategories: ['全部', '播客', '音乐', '游戏', '直播', '旅游', '最近上传', '发现新视频'], follow: '关注', following: '已关注', empty: '没有找到匹配的短剧。', loading: '正在从服务端加载…', apiFallback: '服务端暂不可用,暂时展示演示目录。', playbackUnavailable: '请选择剧集开始播放。', swipeHint: '向上滑动发现下一个故事' }
|
||||
};
|
||||
export function getLocale(code) { return locales[code] || locales.id; }
|
||||
+31
-13
@@ -1,5 +1,5 @@
|
||||
// Author: fruit.com.cn
|
||||
import { Bell, Bookmark, Compass, History, Home, Menu, Search, UserRound, X } from 'lucide-react';
|
||||
import { Bell, Bookmark, ChevronDown, ChevronRight, Compass, Film, History, Home, ListVideo, Menu, Mic, Music2, Plus, Radio, Search, ThumbsUp, UserRound, Video, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../app/store';
|
||||
@@ -9,7 +9,9 @@ export function AppShell() {
|
||||
const { locale, query, setQuery, sidebarOpen, setSidebarOpen } = useAppStore();
|
||||
const navigate = useNavigate();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const navItems = [[Home, locale.home, '/'], [Compass, locale.shorts, '/shorts'], [UserRound, locale.subscriptions, '/subscriptions'], [Bookmark, locale.library, '/library'], [History, locale.history, '/history']];
|
||||
const primaryItems = [[Home, locale.home, '/'], [Compass, locale.shorts, '/shorts'], [UserRound, locale.subscriptions, '/subscriptions']];
|
||||
const libraryItems = [[History, locale.history, '/history'], [ListVideo, locale.playlists, '/library'], [Bookmark, locale.watchLater, '/library'], [ThumbsUp, locale.likedVideos, '/library'], [Video, locale.myVideos, '/library']];
|
||||
const exploreItems = [[Music2, locale.music, '/search?genre=music'], [Film, locale.movies, '/search?genre=movies'], [Radio, locale.live, '/shorts']];
|
||||
const submitSearch = (event) => {
|
||||
event.preventDefault();
|
||||
navigate(`/search?q=${encodeURIComponent(query)}`);
|
||||
@@ -28,17 +30,19 @@ export function AppShell() {
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={locale.search} aria-label={locale.search} />
|
||||
<button aria-label="Search" type="submit"><Search size={19} /></button>
|
||||
</form>
|
||||
<button className="voice-search icon-button" aria-label={locale.voiceSearch} type="button"><Mic size={20} /></button>
|
||||
<div className="top-actions">
|
||||
<button className="icon-button mobile-search" onClick={() => setSearchOpen(!searchOpen)} aria-label={locale.search} type="button"><Search size={20} /></button>
|
||||
<LanguageSwitcher />
|
||||
<Bell size={19} aria-hidden="true" />
|
||||
<span className="profile-chip" aria-label="Profile">N</span>
|
||||
<button className="create-button" type="button"><Plus size={19} /><span>{locale.create}</span></button>
|
||||
<button className="icon-button notification-button" aria-label={locale.notifications} type="button"><Bell size={19} /></button>
|
||||
<span className="profile-chip" aria-label={locale.account}>N</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside className={`sidebar ${sidebarOpen ? 'open' : 'closed'}`}>
|
||||
<nav>
|
||||
{navItems.map(([Icon, label, to]) => (
|
||||
<nav className="sidebar-group">
|
||||
{primaryItems.map(([Icon, label, to]) => (
|
||||
<NavLink to={to} end={to === '/'} key={to}>
|
||||
<Icon size={19} />
|
||||
<span className="sidebar-label">{label}</span>
|
||||
@@ -46,17 +50,31 @@ export function AppShell() {
|
||||
))}
|
||||
</nav>
|
||||
<div className="nav-rule" />
|
||||
<small>GENRE POPULER</small>
|
||||
{locale.genres.slice(1).map((genre) => (
|
||||
<button className="genre-link" key={genre} onClick={() => navigate(`/search?genre=${encodeURIComponent(genre)}`)} type="button">
|
||||
<span className="sidebar-label">{genre}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="sidebar-group">
|
||||
<div className="sidebar-group-title"><span>{locale.you}</span><ChevronRight size={16} /></div>
|
||||
{libraryItems.map(([Icon, label, to]) => (
|
||||
<NavLink to={to} key={label}>
|
||||
<Icon size={19} />
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<div className="nav-rule" />
|
||||
<div className="sidebar-group">
|
||||
<div className="sidebar-group-title">{locale.explore}</div>
|
||||
{exploreItems.map(([Icon, label, to]) => (
|
||||
<NavLink to={to} key={label}>
|
||||
<Icon size={19} />
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<button className="genre-link" type="button"><ChevronDown size={19} /><span className="sidebar-label">{locale.more}</span></button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className={`content ${sidebarOpen ? '' : 'content-wide'}`}><Outlet /></main>
|
||||
<nav className="bottom-nav">
|
||||
{navItems.slice(0, 4).map(([Icon, label, to]) => <NavLink to={to} key={to}><Icon size={19} />{label}</NavLink>)}
|
||||
{primaryItems.map(([Icon, label, to]) => <NavLink to={to} key={to}><Icon size={19} />{label}</NavLink>)}
|
||||
</nav>
|
||||
{sidebarOpen && <button className="scrim mobile-only" onClick={() => setSidebarOpen(false)} aria-label="Close" type="button"><X /></button>}
|
||||
</>
|
||||
|
||||
@@ -3,5 +3,6 @@ import { createRoot } from 'react-dom/client';
|
||||
import { App } from './app/App';
|
||||
import './styles.css';
|
||||
import './enterprise.css';
|
||||
import './youtube-overrides.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
+27
-22
@@ -9,7 +9,8 @@ import { appApi } from '../services/appApi';
|
||||
import { normalizeDrama, uniqueDramas } from '../services/normalizers';
|
||||
import { useApi } from '../hooks/useApi';
|
||||
|
||||
const SHORTS_LIMIT = 6;
|
||||
const SHORTS_LIMIT = 5;
|
||||
const FIRST_SHELF_LIMIT = 5;
|
||||
const ALL_GENRES_INDEX = 0;
|
||||
|
||||
function normalizeHomeFeed(payload, localeCode) {
|
||||
@@ -26,7 +27,8 @@ function filterByGenre(items, genre, localeGenres) {
|
||||
|
||||
export function HomePage() {
|
||||
const { locale } = useAppStore();
|
||||
const [genre, setGenre] = useState(locale.genres[ALL_GENRES_INDEX]);
|
||||
const categoryItems = locale.homeCategories || locale.genres;
|
||||
const [genre, setGenre] = useState(categoryItems[ALL_GENRES_INDEX]);
|
||||
const api = useApi(
|
||||
() => appApi.homeFeed().then((payload) => normalizeHomeFeed(payload, locale.code)),
|
||||
[locale.code],
|
||||
@@ -34,34 +36,28 @@ export function HomePage() {
|
||||
const remoteItems = api.data || [];
|
||||
const fallbackItems = searchCatalog('', genre, locale.genres.indexOf(genre));
|
||||
const items = useMemo(
|
||||
() => filterByGenre(remoteItems.length ? remoteItems : fallbackItems, genre, locale.genres),
|
||||
[fallbackItems, genre, locale.genres, remoteItems],
|
||||
() => filterByGenre(remoteItems.length ? remoteItems : fallbackItems, genre, categoryItems),
|
||||
[categoryItems, fallbackItems, genre, remoteItems],
|
||||
);
|
||||
const shortsItems = (remoteItems.length ? remoteItems : videos).slice(0, SHORTS_LIMIT);
|
||||
const firstShelf = items.slice(0, FIRST_SHELF_LIMIT);
|
||||
const secondShelf = items.slice(FIRST_SHELF_LIMIT);
|
||||
|
||||
useEffect(() => {
|
||||
setGenre(locale.genres[ALL_GENRES_INDEX]);
|
||||
}, [locale.code, locale.genres]);
|
||||
setGenre(categoryItems[ALL_GENRES_INDEX]);
|
||||
}, [categoryItems, locale.code]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="youtube-home-header" aria-labelledby="home-feed-title">
|
||||
<div>
|
||||
<span className="eyebrow">YMB HOME</span>
|
||||
<h1 id="home-feed-title">{locale.picks}</h1>
|
||||
<p>{locale.description}</p>
|
||||
</div>
|
||||
<span className="feed-count">{items.length} {locale.videoCount}</span>
|
||||
</section>
|
||||
|
||||
<section className="youtube-home-feed" aria-labelledby="recommended-title">
|
||||
<SectionHeading title={locale.picks} action={locale.viewAll} />
|
||||
<div className="chip-row" aria-label="Genre filters">
|
||||
{locale.genres.map((item) => (
|
||||
<section className="youtube-home-feed" aria-label={locale.home}>
|
||||
<div className="youtube-category-bar" role="tablist" aria-label="Genre filters">
|
||||
{categoryItems.map((item) => (
|
||||
<button
|
||||
className={`chip ${genre === item ? 'active' : ''}`}
|
||||
className={`youtube-category-chip ${genre === item ? 'active' : ''}`}
|
||||
key={item}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={genre === item}
|
||||
onClick={() => setGenre(item)}
|
||||
>
|
||||
{item}
|
||||
@@ -71,16 +67,25 @@ export function HomePage() {
|
||||
{api.loading && <div className="api-note">{locale.loading}</div>}
|
||||
{api.error && <div className="api-note warning">{locale.apiFallback}</div>}
|
||||
<div className="video-grid" id="recommended-title">
|
||||
{items.map((video) => <MediaCard key={video.id} video={video} />)}
|
||||
{firstShelf.map((video) => <MediaCard key={video.id} video={video} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="youtube-shorts-shelf" aria-labelledby="shorts-title">
|
||||
<SectionHeading title={locale.shortTitle} action={locale.explore} />
|
||||
<div className="youtube-shelf-heading">
|
||||
<h2 id="shorts-title"><span className="shorts-mark">▶</span>{locale.shortTitle}</h2>
|
||||
<button type="button">{locale.explore}</button>
|
||||
</div>
|
||||
<div className="shorts-row" id="shorts-title">
|
||||
{shortsItems.map((video) => <MediaCard key={`short-${video.id}`} video={video} compact />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{secondShelf.length > 0 && <section className="youtube-home-feed youtube-home-feed-secondary" aria-label={locale.picks}>
|
||||
<div className="video-grid">
|
||||
{secondShelf.map((video) => <MediaCard key={video.id} video={video} />)}
|
||||
</div>
|
||||
</section>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/* Author: fruit.com.cn */
|
||||
:root {
|
||||
--bg: #fff;
|
||||
--surface: #fff;
|
||||
--line: #e5e5e5;
|
||||
--text: #0f0f0f;
|
||||
--muted: #606060;
|
||||
--accent: #ff0033;
|
||||
--sidebar: 240px;
|
||||
}
|
||||
|
||||
body { background: var(--bg); color: var(--text); font-family: Arial, Helvetica, sans-serif; }
|
||||
.topbar { height: 56px; gap: 16px; padding: 0 20px; background: #fff; border-bottom: 1px solid var(--line); box-shadow: none; }
|
||||
.topbar-menu { color: var(--text); }
|
||||
.topbar-menu:hover, .voice-search:hover, .notification-button:hover { background: #f2f2f2; }
|
||||
.brand { min-width: 138px; height: 40px; }
|
||||
.brand img { width: 104px; height: 38px; object-fit: contain; }
|
||||
.search-form { height: 40px; max-width: 640px; flex: 1; margin: 0 0 0 clamp(18px, 7vw, 110px); border-radius: 22px; }
|
||||
.search-form input { height: 40px; border-color: #c6c6c6; background: #fff; color: var(--text); }
|
||||
.search-form button { height: 40px; background: #f8f8f8; color: var(--text); border-color: #c6c6c6; }
|
||||
.voice-search { flex: none; width: 40px; height: 40px; border-radius: 50%; color: var(--text); }
|
||||
.top-actions { gap: 14px; margin-left: auto; color: var(--text); }
|
||||
.create-button { display: flex; align-items: center; gap: 6px; border: 0; border-radius: 22px; padding: 9px 14px; background: #f2f2f2; color: var(--text); font-weight: 600; cursor: pointer; }
|
||||
.create-button:hover { background: #e5e5e5; }
|
||||
.notification-button { width: 40px; height: 40px; border-radius: 50%; }
|
||||
.language-trigger { border-color: #d6d6d6; background: #fff; color: var(--text); }
|
||||
.profile-chip { background: #065fd4; color: #fff; }
|
||||
|
||||
.sidebar { top: 56px; width: var(--sidebar); padding: 12px; background: #fff; border: 0; box-shadow: none; color: var(--text); }
|
||||
.sidebar.closed { width: 72px; padding: 12px 8px; }
|
||||
.sidebar nav a, .genre-link { min-height: 40px; padding: 9px 12px; border-radius: 10px; color: var(--text); gap: 22px; }
|
||||
.sidebar nav a:hover, .genre-link:hover, .sidebar nav a.active { background: #f2f2f2; color: var(--text); }
|
||||
.sidebar-group { display: grid; gap: 2px; }
|
||||
.sidebar-group-title { display: flex; align-items: center; gap: 7px; padding: 12px 12px 6px; font-size: 16px; font-weight: 700; }
|
||||
.sidebar-group-title svg { color: var(--muted); }
|
||||
.sidebar small { display: none; }
|
||||
.sidebar.closed .sidebar-group-title, .sidebar.closed .sidebar-label { display: none; }
|
||||
.sidebar.closed nav a, .sidebar.closed .genre-link { justify-content: center; gap: 0; }
|
||||
.sidebar.closed .nav-rule { display: block; margin: 12px 6px; }
|
||||
.nav-rule { height: 1px; background: var(--line); margin: 12px 0; }
|
||||
|
||||
.content { max-width: none; padding: 12px 24px 80px 264px; margin: 0; }
|
||||
.content-wide { padding-left: 96px; }
|
||||
.youtube-home-header { display: none; }
|
||||
.youtube-home-feed, .youtube-shorts-shelf { margin-bottom: 40px; }
|
||||
.youtube-category-bar { position: sticky; top: 56px; z-index: 2; display: flex; gap: 12px; overflow-x: auto; padding: 12px 0; background: #fff; scrollbar-width: none; }
|
||||
.youtube-category-bar::-webkit-scrollbar { display: none; }
|
||||
.youtube-category-chip { flex: none; border: 0; border-radius: 8px; padding: 9px 14px; background: #f2f2f2; color: var(--text); white-space: nowrap; cursor: pointer; font-size: 14px; }
|
||||
.youtube-category-chip:hover { background: #e5e5e5; }
|
||||
.youtube-category-chip.active { background: var(--text); color: #fff; }
|
||||
.video-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 36px 16px; }
|
||||
.media-card { min-width: 0; }
|
||||
.media-thumb { border-radius: 10px; background: #f2f2f2; }
|
||||
.media-thumb img { display: block; }
|
||||
.media-meta { align-items: flex-start; gap: 12px; padding-top: 12px; }
|
||||
.channel-avatar { width: 36px; height: 36px; background: #e5e5e5; color: var(--text); }
|
||||
.media-copy { min-width: 0; flex: 1; }
|
||||
.media-title { display: -webkit-box; overflow: hidden; color: var(--text); font-size: 16px; line-height: 1.35; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.media-meta small { color: var(--muted); font-size: 13px; line-height: 1.5; }
|
||||
.media-more { flex: none; border: 0; background: transparent; color: var(--text); padding: 4px; cursor: pointer; }
|
||||
|
||||
.youtube-shorts-shelf { padding: 20px 0 26px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.youtube-shelf-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
|
||||
.youtube-shelf-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; color: var(--text); font-size: 24px; }
|
||||
.youtube-shelf-heading button { border: 0; background: transparent; color: #065fd4; font-weight: 600; cursor: pointer; }
|
||||
.shorts-mark { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 13px; }
|
||||
.shorts-row { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; }
|
||||
.shorts-row .media-thumb { aspect-ratio: 9 / 16; border-radius: 10px; }
|
||||
.shorts-row .media-meta { display: none; }
|
||||
.short-media-title { display: -webkit-box; overflow: hidden; margin-top: 9px; color: var(--text); font-size: 14px; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.language-menu { background: #fff; border-color: var(--line); box-shadow: 0 8px 24px #0002; }
|
||||
.language-menu button { color: var(--text); }
|
||||
.language-menu button:hover, .language-menu button.selected { background: #f2f2f2; color: var(--text); }
|
||||
|
||||
@media (min-width: 1500px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
||||
@media (max-width: 1100px) and (min-width: 761px) { .video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .shorts-row { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
||||
@media (max-width: 760px) {
|
||||
.topbar { height: 56px; padding: 0 12px; gap: 9px; }
|
||||
.brand { min-width: 74px; }
|
||||
.brand img { width: 74px; height: 34px; }
|
||||
.voice-search, .create-button, .notification-button { display: none; }
|
||||
.language-switcher { display: block; }
|
||||
.language-trigger { width: 38px; height: 38px; justify-content: center; padding: 0; }
|
||||
.language-trigger span, .language-trigger b, .language-trigger svg:last-child { display: none; }
|
||||
.top-actions { gap: 8px; }
|
||||
.search-form { position: absolute; top: 58px; left: 12px; right: 12px; max-width: none; margin: 0; }
|
||||
.content, .content-wide { padding: 0 12px 88px; }
|
||||
.youtube-category-bar { top: 56px; margin: 0 -12px; padding: 10px 12px; }
|
||||
.video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 10px; }
|
||||
.media-meta { gap: 8px; padding-top: 9px; }
|
||||
.channel-avatar { width: 28px; height: 28px; font-size: 12px; }
|
||||
.media-title { font-size: 13px; }
|
||||
.media-meta small { font-size: 11px; }
|
||||
.media-more { display: none; }
|
||||
.youtube-shorts-shelf { margin: 28px -12px 34px; padding: 16px 12px 22px; }
|
||||
.youtube-shelf-heading h2 { font-size: 20px; }
|
||||
.shorts-row { display: flex; gap: 10px; overflow-x: auto; margin: 0; }
|
||||
.shorts-row .media-card { flex: 0 0 150px; }
|
||||
.shorts-row .media-thumb { aspect-ratio: 9 / 16; }
|
||||
.youtube-home-feed-secondary { margin-top: 0; }
|
||||
.bottom-nav { height: 64px; background: #fff; border-top: 1px solid var(--line); }
|
||||
.bottom-nav a { color: var(--muted); }
|
||||
.bottom-nav a.active { color: var(--text); }
|
||||
.sidebar { display: none; top: 56px; bottom: 64px; width: 246px; padding: 16px 12px; box-shadow: 16px 0 30px #0008; }
|
||||
.sidebar.open { display: block; }
|
||||
.sidebar.closed { display: none; }
|
||||
.scrim { background: #0006; }
|
||||
}
|
||||
Reference in new issue
Block a user