// discover.jsx — Discover page (Vidu-style, uses shared components) const _discoverAPI = { banners: (position = 'discover') => fetch('/api/v1/banners?position=' + position).then(r => r.json()), templates: (mediaType = '', category = '', q = '', page = 1, pageSize = 20) => { let url = '/api/v1/templates?page=' + page + '&page_size=' + pageSize; if (mediaType) url += '&media_type=' + mediaType; if (category) url += '&category=' + category; if (q) url += '&q=' + encodeURIComponent(q); return fetch(url).then(r => r.json()); }, }; // ─── Banner carousel ────────────────────────────────────────────────────────── const BannerCarousel = ({ banners }) => { const { isMobile } = useBreakpoint(); if (isMobile || !banners || banners.length === 0) return null; return (
3} renderItem={(b) => (
{b.title}
{b.subtitle &&
{b.subtitle}
}
)} />
); }; // InspirationCard is now in shared.jsx (window.InspirationCard) // ─── Grid with NEW/HOT badges ──────────────────────────────────────────────── const DiscoverGrid = ({ templates }) => { const byDate = [...templates].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); const newIds = new Set(byDate.slice(0, 4).map(t => t.id)); const byUse = [...templates].filter(t => (t.use_count || 0) > 0).sort((a, b) => (b.use_count || 0) - (a.use_count || 0)); const hotIds = new Set(byUse.slice(0, 3).map(t => t.id)); // Cap column count so columns aren't empty when few items const cols = Math.min(5, Math.max(2, templates.length)); return (
{templates.map(t => ( ))}
); }; // ─── Main screen ────────────────────────────────────────────────────────────── const DiscoverScreen = () => { const { t } = useI18n(); const { isMobile, w } = useBreakpoint(); const pagePad = isMobile ? '12px 12px 80px' : w >= 2560 ? '32px 48px 60px' : '24px 24px 60px'; // Google OAuth callback is handled globally by api.js const [banners, setBanners] = React.useState(null); const [templates, setTemplates] = React.useState([]); const [loading, setLoading] = React.useState(true); const [page, setPage] = React.useState(1); const [hasMore, setHasMore] = React.useState(true); const [tab, setTab] = React.useState('all'); const [category, setCategory] = React.useState(''); // '' | 'work' | 'template' const [searchInput, setSearchInput] = React.useState(''); const [q, setQ] = React.useState(''); // 实际请求用的 query(debounced) const loaderRef = React.useRef(null); React.useEffect(() => { _discoverAPI.banners('discover').then(d => setBanners(d.items || [])); }, []); // Debounce search input → q(350ms) React.useEffect(() => { const timer = setTimeout(() => setQ(searchInput.trim()), 350); return () => clearTimeout(timer); }, [searchInput]); // Reset on tab / category / q change React.useEffect(() => { setPage(1); setHasMore(true); setLoading(true); }, [tab, category, q]); // Fetch page React.useEffect(() => { if (!hasMore && page > 1) return; setLoading(true); const mediaType = tab === 'all' ? '' : tab; _discoverAPI.templates(mediaType, category, q, page, 20).then(d => { const items = d.items || []; setTemplates(prev => page === 1 ? items : [...prev, ...items]); setHasMore(items.length >= 20); setLoading(false); }); }, [tab, category, q, page]); // Infinite scroll: observe sentinel React.useEffect(() => { const el = loaderRef.current; if (!el) return; const obs = new IntersectionObserver(entries => { if (entries[0].isIntersecting && hasMore && !loading) { setPage(p => p + 1); } }, { rootMargin: '200px' }); obs.observe(el); return () => obs.disconnect(); }, [hasMore, loading]); return (
{/* ── Banners ── */} {banners === null ? ( ) : banners.length > 0 ? ( ) : null} {/* ── Header ── */}

{t('discover.title')}

{/* 搜索框 */}
setSearchInput(e.target.value)} placeholder={t('discover.searchPlaceholder') || '搜索作品 / 提示词...'} aria-label="搜索" style={{ flex: 1, minWidth: 0, border: 'none', background: 'transparent', outline: 'none', color: 'var(--fg-0)', fontSize: 13, }} /> {searchInput && ( )}
{/* 移动端隐藏 Submit;桌面端跳到视频创建页 */} {!isMobile && ( {t('discover.submit')} )}
{/* ── Filter pills ── */}
{[ { value: 'all', label: t('discover.all') }, { value: 'image', label: t('creation.image') }, { value: 'video', label: t('nav.video') }, ].map(f => ( ))}
{[ { value: '', label: t('discover.unlimited') }, { value: 'work', label: t('discover.works') }, { value: 'template', label: t('discover.templates') }, ].map(f => ( ))}
{/* ── Grid ── */} {templates.length > 0 ? ( ) : !loading ? ( ) : null} {/* Loading / infinite scroll sentinel */} {loading && }
{!hasMore && templates.length > 0 && (
— {t('discover.allLoaded')} —
)}
); }; Object.assign(window, { DiscoverScreen });