// 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 (
);
};
/* ── 计算用户头像 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) => (
))}
{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 && (
)}
{signedIn ? (
<>
{credits.toLocaleString()}
{!isMobile && <>·{t('nav.credits')}>}
{!isMobile && }
{navUserAvatar ?
: navInitials}
>
) : (
<>
{!isMobile && {t('nav.login')}}
{isMobile ? t('nav.login') : t('nav.getStarted')}
>
)}
);
};
// 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 }) => (
);
// 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
setErr(true)} {...props} />;
};
const SafeMedia = ({ src, type, style, ...props }) => {
const isVideo = type === 'video' || /\.(mp4|mov|webm)(\?|$)/i.test(src || '');
if (isVideo) {
return ;
}
return ;
};
// ─── Global dialog (replaces native confirm/alert/prompt) ────────────────────
// Usage: await ytConfirm('确认删除?') → true/false
// await ytAlert('操作成功') → void
// await ytPrompt('输入标题') → string/null
const _dlgRoot = document.createElement('div');
document.body.appendChild(_dlgRoot);
const _renderDlg = (el) => { ReactDOM.createRoot(_dlgRoot).render(el); };
const _closeDlg = () => { ReactDOM.createRoot(_dlgRoot).render(null); };
const ytConfirm = (message, opts = {}) => new Promise(resolve => {
const wt = window.t || (k => k);
const { confirmText = wt('common.confirm'), cancelText = wt('common.cancel'), danger = false } = opts;
const modalWidth = Math.min(400, window.innerWidth - 32);
_renderDlg(
{message}
);
});
const ytAlert = (message) => new Promise(resolve => {
const wt = window.t || (k => k);
const modalWidth = Math.min(400, window.innerWidth - 32);
_renderDlg(
{message}
);
});
const ytPrompt = (message, defaultVal = '') => new Promise(resolve => {
const wt = window.t || (k => k);
const modalWidth = Math.min(400, window.innerWidth - 32);
let val = defaultVal;
const render = () => _renderDlg(
{message}
{ val = e.target.value; }}
onKeyDown={e => { if (e.key === 'Enter') { _closeDlg(); resolve(val.trim() || null); } if (e.key === 'Escape') { _closeDlg(); resolve(null); } }}
className="yt-input" style={{ width: '100%', height: 42, fontSize: 14, marginBottom: 16 }} />
);
render();
});
// ─── Empty state ──────────────────────────────────────────────────────────────
const EmptyState = ({ icon = 'image', title, subtitle, action }) => {
const { t } = useI18n();
const displayTitle = title !== undefined ? title : t('shared.empty.noContent');
return (
{displayTitle}
{subtitle &&
{subtitle}
}
{action}
);
};
// ─── Publish modal (title + type selection before publishing) ─────────────────
const PublishModal = ({ onConfirm, onCancel }) => {
const { t } = useI18n();
const [title, setTitle] = React.useState('');
const [pubType, setPubType] = React.useState('work');
const [coverFile, setCoverFile] = React.useState(null);
const [coverPreview, setCoverPreview] = React.useState(null);
const coverRef = React.useRef(null);
const handleCoverSelect = e => {
const f = e.target.files?.[0]; e.target.value = '';
if (!f) return;
setCoverFile(f);
const url = URL.createObjectURL(f);
setCoverPreview(url);
};
const clearCover = () => { setCoverFile(null); setCoverPreview(null); };
const isAnimated = coverFile && /\.(gif|webp|apng)$/i.test(coverFile.name);
const TypeBtn = ({ value, label, desc, icon }) => {
const active = pubType === value;
return (
setPubType(value)} style={{
flex: 1, padding: '14px 16px', borderRadius: 10, cursor: 'pointer',
border: active ? '2px solid var(--accent)' : '1px solid var(--line-2)',
background: active ? 'rgba(34,211,238,0.06)' : 'rgba(255,255,255,0.02)',
transition: 'all .12s',
}}>
{icon}
{label}
{desc}
);
};
const modalWidth = Math.min(480, window.innerWidth - 32);
return ReactDOM.createPortal(
{ if (e.target === e.currentTarget) onCancel(); }}>
{t('shared.publish.title')}
{/* Type selector */}
{/* Title */}
setTitle(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && title.trim()) onConfirm(title.trim(), pubType === 'template', coverFile); if (e.key === 'Escape') onCancel(); }}
placeholder={pubType === 'template' ? t('shared.publish.titlePlaceholder.template') : t('shared.publish.titlePlaceholder.work')}
className="yt-input"
style={{ width: '100%', marginBottom: 14, height: 44, fontSize: 15 }} />
{/* Cover image (optional) */}
{t('shared.publish.customCover')}
({t('shared.publish.coverHint')})
{coverPreview ? (

{isAnimated &&
{t('shared.publish.animated')}}
) : (
)}
,
document.body
);
};
// ─── Shared masonry card (used by discover + user-profile) ───────────────────
const InspirationCard = ({ tpl, isNew, isHot }) => {
const { t } = useI18n();
const isVideo = tpl.media_type === 'video';
const [hover, setHover] = React.useState(false);
const [imgLoaded, setImgLoaded] = React.useState(false);
const [liked, setLiked] = React.useState(!!tpl.is_liked);
const [likes, setLikes] = React.useState(tpl.like_count || 0);
const [cdnDomain, setCdnDomain] = React.useState(() => _sharedCdnDomain || window.YiteAPI?.cdnDomain || '');
const videoRef = React.useRef(null);
const src = tpl.cover_url || tpl.result_url;
const thumbSrc = _sharedThumbUrl(src, tpl.media_type, cdnDomain, 800);
React.useEffect(() => {
if (_sharedCdnDomain) return;
_sharedFetchCdn().then(d => setCdnDomain(d));
}, []);
React.useEffect(() => {
if (!isVideo || !videoRef.current) return;
if (hover) { videoRef.current.play().catch(() => {}); }
else { videoRef.current.pause(); videoRef.current.currentTime = 0; }
}, [hover, isVideo]);
const handleLike = (e) => {
e.stopPropagation(); e.preventDefault();
if (!window.YiteAPI?.isLoggedIn?.()) { showLoginModal(); return; }
if (liked) {
setLiked(false); setLikes(c => Math.max(0, c - 1));
window.YiteAPI.templates.unlike(tpl.id).catch(() => { setLiked(true); setLikes(c => c + 1); });
} else {
setLiked(true); setLikes(c => c + 1);
window.YiteAPI.templates.like(tpl.id).catch(() => { setLiked(false); setLikes(c => Math.max(0, c - 1)); });
}
};
const userInitial = (tpl.user_nickname || tpl.nickname || '?')[0].toUpperCase();
const showUser = tpl.user_id || tpl.user_nickname;
const useCount = tpl.use_count || 0;
const likeCount = tpl.like_count || 0;
// 整张卡可点击:作品 → 创作页(带模板 ID);模板 → 模板详情页
const handleCardClick = () => {
if (tpl.is_template) {
window.location.href = '/template/' + tpl.id;
} else {
const base = tpl.media_type === 'video' ? '/create2video' : '/create2img';
const tt = tpl.task_type || '';
let tab = 'ref';
if (tt.includes('文生')) tab = 'text';
else if (tt.includes('开始') || tt.includes('结束')) tab = 'frames';
window.location.href = base + '?tab=' + tab + '&from_template=' + tpl.id;
}
};
const handleCardKey = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCardClick(); } };
return (
setHover(true)} onMouseLeave={() => setHover(false)}>
{/* Media — video: OSS JPEG (fast) or
);
};
// ─── Google One Tap login ────────────────────────────────────────────────────
// Loads Google GIS script, fetches client_id from /api/v1/config,
// and shows One Tap popup for non-logged-in users.
const _googleOneTapInitialized = { current: false };
const initGoogleOneTap = () => {
// Skip if already logged in or already initialized
if (window.YiteAPI?.isLoggedIn?.()) return;
if (_googleOneTapInitialized.current) return;
_googleOneTapInitialized.current = true;
// Define global callback before script loads
window.__yiteGoogleOneTapCallback = async (response) => {
if (!response || !response.credential) return;
try {
const res = await fetch('/api/v1/auth/google/onetap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: response.credential }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
if (data.token && window.YiteAPI) {
window.YiteAPI.setToken(data.token);
if (data.user) window.YiteAPI.setUser(data.user);
window.location.reload();
}
} catch (e) {
console.error('[GoogleOneTap] callback error:', e);
}
};
// Fetch config to get client_id, then load script
fetch('/api/v1/config')
.then(r => r.json())
.then(cfg => {
const clientId = cfg.google_client_id;
if (!clientId) return; // no client_id configured, skip
// Load Google GIS script
const script = document.createElement('script');
script.src = 'https://accounts.google.com/gsi/client';
script.async = true;
script.defer = true;
script.onload = () => {
try {
window.google.accounts.id.initialize({
client_id: clientId,
callback: window.__yiteGoogleOneTapCallback,
auto_select: true,
});
window.google.accounts.id.prompt();
} catch (e) {
console.error('[GoogleOneTap] initialize error:', e);
}
};
script.onerror = () => {
console.warn('[GoogleOneTap] failed to load GIS script');
};
document.head.appendChild(script);
})
.catch(e => {
console.warn('[GoogleOneTap] failed to fetch config:', e);
});
};
// React hook to trigger One Tap in components
const useGoogleOneTap = () => {
React.useEffect(() => {
if (!window.YiteAPI?.isLoggedIn?.()) {
initGoogleOneTap();
}
}, []);
};
// ─── Login modal (global, replaces /login page redirect) ─────────────────────
const LoginModal = ({ onClose, onSuccess }) => {
const { t } = useI18n();
const [mode, setMode] = React.useState('login'); // 'login' | 'signup'
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const [nickname, setNickname] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState('');
const handleGoogleLogin = () => {
// Redirect to Google OAuth (traditional flow as fallback)
window.location.href = '/api/v1/auth/gmail?redirect=' + encodeURIComponent(window.location.href);
};
const handleSubmit = async () => {
if (!email.trim() || !password.trim()) { setError(t('shared.login.emailRequired')); return; }
if (mode === 'signup' && !nickname.trim()) { setError(t('shared.login.nicknameRequired') || t('shared.login.emailRequired')); return; }
setLoading(true); setError('');
try {
const url = mode === 'login' ? '/api/v1/auth/login' : '/api/v1/auth/register';
const body = mode === 'login'
? { email: email.trim(), password }
: { email: email.trim(), password, nickname: nickname.trim() };
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
if (res.error) { setError(res.error); setLoading(false); return; }
if (res.token && window.YiteAPI) {
window.YiteAPI.setToken(res.token);
if (res.user) window.YiteAPI.setUser(res.user);
}
setLoading(false);
if (onSuccess) onSuccess();
else window.location.reload();
} catch (e) { setError(e.message || t('common.error')); setLoading(false); }
};
const modalWidth = Math.min(420, window.innerWidth - 32);
return ReactDOM.createPortal(
{ if (e.target === e.currentTarget && onClose) onClose(); }}>
e.stopPropagation()}>
{onClose &&
}
{/* Logo + title */}
{mode === 'login' ? t('shared.login.welcome') : t('shared.login.create')}
{mode === 'login' ? t('shared.login.subtitle') : t('shared.login.registerSub')}
{/* Google button */}
{/* Divider */}
{t('shared.login.orEmail')}
{/* Form */}
{mode === 'signup' && (
setNickname(e.target.value)} placeholder={t('shared.login.nickname') || 'Nickname'}
className="yt-input" style={{ height: 44, fontSize: 14 }} />
)}
setEmail(e.target.value)} placeholder={t('shared.login.email')}
onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); }}
className="yt-input" style={{ height: 44, fontSize: 14 }} />
setPassword(e.target.value)} placeholder={t('shared.login.password')}
onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); }}
className="yt-input" style={{ height: 44, fontSize: 14 }} />
{error &&
{error}
}
{/* Toggle mode */}
{mode === 'login' ? (
<>{t('shared.login.noAccount')} >
) : (
<>{t('shared.login.haveAccount')} >
)}
,
document.body
);
};
// Global helper: show login modal anywhere
// Usage: showLoginModal() → opens modal, returns Promise that resolves when logged in
let _loginModalResolver = null;
const _loginModalRoot = document.createElement('div');
document.body.appendChild(_loginModalRoot);
const showLoginModal = () => new Promise(resolve => {
_loginModalResolver = resolve;
const render = () => ReactDOM.createRoot(_loginModalRoot).render(
{ ReactDOM.createRoot(_loginModalRoot).render(null); resolve(false); }}
onSuccess={() => { ReactDOM.createRoot(_loginModalRoot).render(null); resolve(true); }}
/>
);
render();
});
// ─── Footer (landing + legal pages) ──────────────────────────────────────────
const Footer = () => {
const { t } = useI18n();
const { isMobile } = useBreakpoint();
return (
);
};
// ─── PWA install prompt ─────────────────────────────────────────────────────
// Inject manifest link into once
if (!document.querySelector('link[rel="manifest"]')) {
const link = document.createElement('link');
link.rel = 'manifest';
link.href = '/frontend/manifest.json';
document.head.appendChild(link);
// Also set theme-color meta
if (!document.querySelector('meta[name="theme-color"]')) {
const meta = document.createElement('meta');
meta.name = 'theme-color';
meta.content = '#06080f';
document.head.appendChild(meta);
}
}
// Auto-claim PWA install reward when running in standalone mode
if (window.matchMedia('(display-mode: standalone)').matches && window.YiteAPI?.isLoggedIn?.()) {
const claimed = localStorage.getItem('yt_pwa_claimed');
if (!claimed) {
fetch('/api/v1/user/pwa-install', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (window.YiteAPI?.getToken?.() || '') },
}).then(r => r.json()).then(d => {
if (d.ok && !d.already_claimed && d.reward) {
localStorage.setItem('yt_pwa_claimed', '1');
ytAlert((window.i18n?.getLang?.() === 'zh' ? '恭喜!安装奖励 ' : 'Congratulations! Install reward: +') + d.reward + (window.i18n?.getLang?.() === 'zh' ? ' 额度已到账' : ' credits added'));
} else {
localStorage.setItem('yt_pwa_claimed', '1');
}
}).catch(() => {});
}
}
// Capture the beforeinstallprompt event
let _deferredInstallPrompt = null;
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
_deferredInstallPrompt = e;
});
const PWAInstallBanner = () => {
const { t } = useI18n();
const { isMobile } = useBreakpoint();
const [show, setShow] = React.useState(false);
const [isIOS, setIsIOS] = React.useState(false);
React.useEffect(() => {
// Don't show if already installed (standalone mode)
if (window.matchMedia('(display-mode: standalone)').matches) return;
// Don't show if dismissed recently (24h cooldown)
const dismissed = localStorage.getItem('yt_pwa_dismissed');
if (dismissed && Date.now() - Number(dismissed) < 86400000) return;
const ua = navigator.userAgent || '';
const ios = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
setIsIOS(ios);
// Show after 3 seconds on mobile
if (isMobile) {
const timer = setTimeout(() => setShow(true), 3000);
return () => clearTimeout(timer);
}
}, [isMobile]);
const handleInstall = async () => {
if (_deferredInstallPrompt) {
_deferredInstallPrompt.prompt();
const result = await _deferredInstallPrompt.userChoice;
if (result.outcome === 'accepted') setShow(false);
_deferredInstallPrompt = null;
}
};
const handleDismiss = () => {
setShow(false);
localStorage.setItem('yt_pwa_dismissed', String(Date.now()));
};
if (!show) return null;
return (
{t('pwa.addToHome')}
{isIOS ? t('pwa.iosHint') : t('pwa.quickAccess')}
{!isIOS && _deferredInstallPrompt ? (
) : null}
);
};
// 带 localStorage 持久化的 useState,页面切走再回来时保留值
const usePersistentState = (key, defaultValue) => {
const [value, setValue] = React.useState(() => {
try {
const raw = localStorage.getItem(key);
return raw !== null ? JSON.parse(raw) : defaultValue;
} catch { return defaultValue; }
});
React.useEffect(() => {
try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
}, [key, value]);
return [value, setValue];
};
Object.assign(window, {
Logo, Icon, TopNav, LeftRail, AppHeader, AppShell, Footer,
Skeleton, SkeletonCard, SkeletonGrid, SkeletonBanner, SkeletonList,
SafeImg, SafeMedia, EmptyState, InspirationCard, PublishModal,
ytConfirm, ytAlert, ytPrompt, LoginModal, showLoginModal,
initGoogleOneTap, useGoogleOneTap,
useI18n, useBreakpoint, useCarousel, Carousel, relativeTime, PWAInstallBanner,
usePersistentState,
});