// shared.jsx — small reusable bits for the yiteai screens // ── Shared CDN thumbnail helper ────────────────────────────────────────────── // Delegates to api.js's getCdnDomain() — single fetch, shared across all components. let _sharedCdnDomain = null; const _sharedFetchCdn = () => { if (_sharedCdnDomain !== null) return Promise.resolve(_sharedCdnDomain); const p = window.YiteAPI?.getCdnDomain?.() || Promise.resolve(''); return p.then(d => { _sharedCdnDomain = d; return d; }); }; // Returns an OSS-processed thumbnail URL for CDN assets; falls back to original. const _sharedThumbUrl = (url, tag, cdnDomain, w = 600) => { if (!url || !cdnDomain || !url.startsWith(cdnDomain) || url.includes('?')) return url; if (tag === 'video') return url + `?x-oss-process=video/snapshot,t_1000,f_jpg,w_${w},m_fast`; if (tag === 'image') return url + `?x-oss-process=image/resize,w_${w}/format,webp`; return url; }; const Logo = ({ size = 16 }) => ( yiteai.com ); const Icon = ({ name, size = 16, color = 'currentColor', strokeWidth = 1.6 }) => { const paths = { search: <>, sparkle: , image: <>, pen: <>, video: <>, voice: <>, code: <>, bot: <>, translate: <>, upscale: <>, eraser: <>, star: , arrow: , chevron: , chevronDown: , check: , plus: , download: <>, upload: <>, user: <>, bell: <>, settings: <>, coin: <>, lock: <>, mail: <>, google: , apple: , refresh: <>, grid: <>, list: <>, fire: , folder: , history: <>, layers: <>, aspect: <>, shuffle: <>, close: , menu: , eye: <>, flash: , link: <>, file: <>, compass: <>, }; return ( {paths[name] || } ); }; /* ── 计算用户头像 fallback 缩写:多词取首末字母,单词取首字母,邮箱取 @ 前首字母 ──────── */ const computeInitials = (raw) => { if (!raw) return '?'; const s = String(raw).trim(); if (!s) return '?'; // 邮箱:取 @ 前的部分 const before = s.includes('@') ? s.split('@')[0] : s; // 按空格 / · / - / _ 分词 const parts = before.split(/[\s·\-_]+/).filter(Boolean); if (parts.length === 0) return s.charAt(0).toUpperCase(); if (parts.length === 1) return parts[0].charAt(0).toUpperCase(); return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase(); }; /* ── MobileDrawer:移动端侧滑菜单(AppShell + TopNav 共用) ──────── */ const MobileDrawer = ({ open, onClose, signedIn, pageHistory = [], onClearHistory }) => { const { t: tt } = useI18n(); if (!open) return null; const items = [ { icon: 'compass', label: tt('nav.discover') || '发现', href: '/discover' }, { icon: 'image', label: tt('nav.image') || '图片', href: '/create2img?tab=ref' }, { icon: 'video', label: tt('nav.video') || '视频', href: '/create2video?tab=ref' }, { icon: 'layers', label: tt('nav.template') || '模板', href: '/template/_' }, { icon: 'folder', label: tt('nav.assets') || '我的资产', href: '/assets' }, { icon: 'star', label: tt('shared.menu.subscription') || '订阅与套餐', href: '/pricing' }, ...(signedIn ? [ { icon: 'coin', label: tt('shared.menu.orders') || '支付记录', href: '/orders' }, { icon: 'user', label: tt('shared.menu.profile') || '个人中心', href: '/profile' }, ] : [ { icon: 'user', label: tt('nav.login') || '登录', href: '/login' }, ]), ]; return ( <>
{tt('shared.menu.title') || '菜单'}
{/* 主菜单 */}
主菜单
{items.map(m => ( {m.label} ))} {/* 访问历史(仅 AppShell 传入时显示) */} {pageHistory && pageHistory.length > 1 && ( <>
访问历史
{pageHistory.slice(1, 9).map((h, i) => (
{h.title}
{h.url}
))} {onClearHistory && ( )} )}
); }; /* ── i18n React hook ──────────────────────────────────────── */ const useI18n = () => { const [lang, setLangState] = React.useState(window.i18n ? window.i18n.getLang() : 'en'); React.useEffect(() => { const handler = (e) => setLangState(e.detail.lang); window.addEventListener('i18n:change', handler); return () => window.removeEventListener('i18n:change', handler); }, []); const t = window.t || ((k) => k); return { lang, t, setLang: (l) => window.i18n && window.i18n.setLang(l) }; }; /* ── Modal hook:ESC 关闭 + body 滚动锁定 ──────────────────────────── */ // 用法:useModalLock(isOpen, onClose) // - isOpen 为 true 时锁定 body 滚动 + 监听 ESC 键 // - 卸载或 isOpen 变 false 时自动恢复 const useModalLock = (isOpen, onClose) => { React.useEffect(() => { if (!isOpen) return undefined; const onKey = (e) => { if (e.key === 'Escape' && onClose) onClose(); }; window.addEventListener('keydown', onKey); const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { window.removeEventListener('keydown', onKey); document.body.style.overflow = prev; }; }, [isOpen, onClose]); }; /* ── Responsive breakpoint hook ──────────────────────────── */ const useBreakpoint = () => { const [w, setW] = React.useState(typeof window !== 'undefined' ? window.innerWidth : 1200); React.useEffect(() => { const handler = () => setW(window.innerWidth); window.addEventListener('resize', handler); return () => window.removeEventListener('resize', handler); }, []); return { isMobile: w < 768, isTablet: w >= 768 && w < 1024, isDesktop: w >= 1024, w }; }; /* ── Shared relative time formatter ──────────────────────── */ const relativeTime = (iso) => { if (!iso) return ''; const t = window.t || (k => k); const diff = (Date.now() - new Date(iso).getTime()) / 1000; if (diff < 60) return t('time.justNow'); if (diff < 3600) return Math.floor(diff / 60) + t('time.minutesAgo'); if (diff < 86400) return Math.floor(diff / 3600) + t('time.hoursAgo'); return Math.floor(diff / 86400) + t('time.daysAgo'); }; /* ── Reusable carousel hook + component ──────────────────── */ const useCarousel = (len, interval = 3800) => { const [idx, setIdx] = React.useState(0); React.useEffect(() => { if (len <= 1) return; const t = setInterval(() => setIdx(i => (i + 1) % len), interval); return () => clearInterval(t); }, [len, interval]); return [idx, setIdx]; }; /** * Carousel — universal horizontal sliding carousel. * * Props: * items — array of React nodes or data * renderItem(item, index) — render each card (must return stable JSX) * show — visible cards at once (default 1) * gap — gap in px (default 16) * interval — auto-advance ms (default 3500, 0=disabled) * loop — infinite loop (default true) * dots — show nav dots (default true) * dotColor — dot active color (default 'var(--accent)') */ const Carousel = ({ items, renderItem, show = 1, gap = 16, interval = 3500, loop = true, dots = true, dotColor = 'var(--accent)' }) => { const total = items.length; if (total === 0) return null; const cloned = loop ? [...items, ...items.slice(0, show)] : items; const [pos, setPos] = React.useState(0); const [noTrans, setNoTrans] = React.useState(false); const timerRef = React.useRef(null); const maxPos = loop ? total : Math.max(0, total - show); // Auto-advance React.useEffect(() => { if (!interval || total <= show) return; timerRef.current = setInterval(() => setPos(p => p + 1), interval); return () => clearInterval(timerRef.current); }, [interval, total, show]); // Loop reset React.useEffect(() => { if (loop && pos >= total) { const t = setTimeout(() => { setNoTrans(true); setPos(0); requestAnimationFrame(() => requestAnimationFrame(() => setNoTrans(false))); }, 520); return () => clearTimeout(t); } if (!loop && pos > maxPos) setPos(maxPos); }, [pos, total, loop, maxPos]); const cardFlex = `0 0 calc(${100 / show}% - ${gap * (show - 1) / show}px)`; const activeDot = pos % total; return (
{cloned.map((item, i) => (
{renderItem(item, i % total)}
))}
{dots && total > show && (
{Array.from({ length: total }, (_, i) => (
)}
); }; /* ── Language switcher button ─────────────────────────────── */ const LangSwitcher = ({ style }) => { const { lang, setLang } = useI18n(); const next = lang === 'en' ? 'zh' : 'en'; const label = lang === 'en' ? '中文' : 'EN'; return ( ); }; // Top nav bar used across screens const TopNav = ({ active = 'home', credits = 248, signedIn = true, compact = false }) => { const { t } = useI18n(); const { isMobile } = useBreakpoint(); const navUser = window.YiteAPI?.getUser?.(); const navUserAvatar = navUser?.avatar || (navUser?.avatar_oss_key ? (window.YiteAPI?.cdnDomain || '') + '/' + navUser.avatar_oss_key : ''); const navInitials = computeInitials(navUser?.nickname || navUser?.email); // 移动端 hamburger 抽屉(landing 用) const [mobNavOpen, setMobNavOpen] = React.useState(false); // 移动端:minimal bar — 左 hamburger(抽屉) + 中 brand + 右 用户信息/登录 if (isMobile) { return ( <>
{signedIn ? ( ) : ( {t('nav.login') || 'Login'} )}
setMobNavOpen(false)} signedIn={signedIn} /> ); } return (
{!compact && } {!compact && !isMobile && ( )}
); }; // Compact left-rail nav used on all NON-landing app screens. // Inspired by Krea / Higgsfield / Recraft / Sora — narrow icon column, // app-name at top, primary destinations as icon buttons, settings + avatar at the bottom. const LeftRail = ({ active = 'home', credits: creditsProp, hideFooter = false }) => { const { t } = useI18n(); const cachedCoins = window.YiteAPI ? (window.YiteAPI.getUser()?.coins ?? null) : null; const credits = creditsProp !== undefined ? creditsProp : (cachedCoins !== null ? cachedCoins : 0); const items = [ { id: 'discover', icon: 'compass', key: 'nav.discover', href: '/discover' }, { id: 'image', icon: 'image', key: 'nav.image', href: '/create2img?tab=ref' }, { id: 'video', icon: 'video', key: 'nav.video', href: '/create2video?tab=ref' }, { id: 'template', icon: 'layers', key: 'nav.template', href: '/template/_' }, { id: 'assets', icon: 'folder', key: 'nav.assets', href: '/assets' }, ]; const RailButton = ({ item }) => { const isActive = active === item.id; return ( ); }; return ( ); }; // Compact in-app top bar — sits to the right of LeftRail. // Holds the screen title + a context-aware action area; the heavy-weight site nav // (logged-out marketing nav) only lives on the landing page. const AppHeader = ({ title, subtitle, action, breadcrumbs }) => (
{breadcrumbs && (
{breadcrumbs.map((b, i) => ( {i > 0 && } {b} ))}
)}

{title}

{subtitle && {subtitle}}
{action}
); // Unified app shell: LeftRail (fixed) + top bar (user info, right-aligned) + work area. const AppShell = ({ active, children }) => { const { t } = useI18n(); const { isMobile } = useBreakpoint(); const [user, setUser] = React.useState(() => window.YiteAPI?.getUser?.() || null); const userCoins = user?.coins ?? 0; const userInitial = computeInitials(user?.nickname || user?.email); const userAvatar = user?.avatar || (user?.avatar_oss_key ? (window.YiteAPI?.cdnDomain || '') + '/' + user.avatar_oss_key : ''); const [menuOpen, setMenuOpen] = React.useState(false); const isLoggedIn = window.YiteAPI?.isLoggedIn?.() || false; React.useEffect(() => { const syncUser = () => setUser(window.YiteAPI?.getUser?.() || null); window.addEventListener('yt:user-updated', syncUser); window.addEventListener('storage', syncUser); return () => { window.removeEventListener('yt:user-updated', syncUser); window.removeEventListener('storage', syncUser); }; }, []); // Google One Tap for non-logged-in users React.useEffect(() => { if (!isLoggedIn) initGoogleOneTap(); }, [isLoggedIn]); // Close menu on outside click React.useEffect(() => { if (!menuOpen) return; const h = () => setMenuOpen(false); setTimeout(() => window.addEventListener('click', h), 0); return () => window.removeEventListener('click', h); }, [menuOpen]); const handleLogout = () => { if (window.YiteAPI) { window.YiteAPI.setToken(''); window.YiteAPI.setUser(null); } localStorage.removeItem('token'); window.location.href = '/login'; }; // 移动端顶栏:左侧菜单(drawer with 访问历史) + 右侧"+"快捷创建 const [historyOpen, setHistoryOpen] = React.useState(false); const [pageHistory, setPageHistory] = React.useState([]); React.useEffect(() => { try { const raw = localStorage.getItem('yt_page_history'); const list = raw ? JSON.parse(raw) : []; // 当前页面入栈:去重 + 截断到 10 条 const cur = { url: window.location.pathname + window.location.search, title: document.title || window.location.pathname, ts: Date.now() }; const filtered = list.filter(x => x.url !== cur.url).slice(0, 9); const next = [cur, ...filtered]; localStorage.setItem('yt_page_history', JSON.stringify(next)); setPageHistory(next); } catch (e) {} }, []); return (
{!isMobile && }
{/* 移动端顶栏:左 hamburger + 右"+"创建 */} {isMobile && (
YITE AI
)} {/* History Drawer (mobile) */} {isMobile && ( setHistoryOpen(false)} signedIn={isLoggedIn} pageHistory={pageHistory} onClearHistory={() => { localStorage.removeItem('yt_page_history'); setPageHistory(pageHistory.slice(0, 1)); }} /> )} {/* Top bar — 桌面端显示;移动端用户信息已下移到底部"我的" tab */} {!isMobile && (
{/* Coins */}
{userCoins >= 1000 ? (userCoins / 1000).toFixed(1) + 'K' : userCoins} {t('common.credits')}
{/* Notification bell */} {/* User avatar + dropdown */}
{menuOpen && (
e.stopPropagation()}> {/* User info */}
{userAvatar ? : {userInitial}}
{user?.nickname || 'User'}
{user?.email || ''}
{/* Menu items */} {[ { label: t('shared.menu.profile'), icon: 'user', href: '/profile' }, { label: t('shared.menu.subscription'), icon: 'star', href: '/pricing' }, { label: t('shared.menu.orders'), icon: 'coin', href: '/orders' }, { label: t('shared.menu.settings'), icon: 'settings', href: '/profile#settings' }, ].map(item => ( e.currentTarget.style.background = 'rgba(255,255,255,0.06)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> {item.label} ))}
)}
)} {/* Work area */}
{children}
{/* Mobile bottom nav — 5 items, Discover 居中突出,最右侧"我的"展示用户信息 */} {isMobile && ( )}
); }; // ─── Skeleton components ────────────────────────────────────────────────────── const Skeleton = ({ width, height, style, className = '' }) => (
); const SkeletonCard = ({ ratio = '4/3' }) => (
); const SkeletonGrid = ({ count = 10, cols = 'repeat(auto-fill, minmax(240px, 1fr))', ratio = '4/3' }) => (
{Array.from({ length: count }).map((_, i) => )}
); const SkeletonBanner = ({ count = 3 }) => (
{Array.from({ length: count }).map((_, i) => (
))}
); const SkeletonList = ({ count = 5 }) => (
{Array.from({ length: count }).map((_, i) => (
))}
); // ─── Image/Video with fallback ──────────────────────────────────────────────── const SafeImg = ({ src, alt = '', style, fallbackIcon = 'image', ...props }) => { const [err, setErr] = React.useState(false); if (err || !src) { return (
); } return {alt} setErr(true)} {...props} />; }; const SafeMedia = ({ src, type, style, ...props }) => { const isVideo = type === 'video' || /\.(mp4|mov|webm)(\?|$)/i.test(src || ''); if (isVideo) { return