// =========================================================
// FBIF 资讯订阅 · Timeline view (核心界面 · 真·时间线)
// Centered, readable feed on a vertical time axis.
// Date dividers + per-card timestamp dots + connecting line.
// Click a card → opens the reader preview (handled in App).
// =========================================================
// segmented control (shared)
const Seg = ({ value, onChange, options, size = 28 }) => (
{options.map(o => {
const active = value === o.value;
return (
);
})}
);
function clock(iso) {
const d = new Date(iso);
const p = n => String(n).padStart(2, '0');
return `${p(d.getHours())}:${p(d.getMinutes())}`;
}
function dayLabel(iso) {
const d = new Date(iso);
const today = new Date();
const yest = new Date(); yest.setDate(today.getDate() - 1);
const same = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
const wd = ['周日','周一','周二','周三','周四','周五','周六'][d.getDay()];
const md = `${d.getMonth() + 1} 月 ${d.getDate()} 日`;
if (same(d, today)) return `今天 · ${md} ${wd}`;
if (same(d, yest)) return `昨天 · ${md} ${wd}`;
return `${md} ${wd}`;
}
// ---- timeline card ----
const TLCard = ({ entry, active, onOpen, compact = false, narrow = false }) => {
const [hover, setHover] = React.useState(false);
const [focus, setFocus] = React.useState(false);
const tags = entry.tags.map(id => window.FBIF_DATA.TAGS.find(t => t.id === id)).filter(Boolean);
const indexMode = compact || narrow;
const sourceMax = indexMode ? 120 : 180;
const handleKey = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(entry);
}
};
return (
onOpen(entry)} onKeyDown={handleKey}
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
onFocus={() => setFocus(true)} onBlur={() => setFocus(false)}
style={{
position: 'relative', border: '1px solid', borderColor: active ? '#A8C3FB' : hover ? '#C9CDD4' : '#E5E6EB',
borderRadius: 8, background: active ? '#F7FAFF' : '#fff', padding: indexMode ? '10px 12px' : '12px 14px', cursor: 'pointer',
transition: 'border-color .15s, background-color .15s, box-shadow .15s',
boxShadow: focus ? '0 0 0 3px rgba(20,86,240,.14)' : hover ? '0 2px 8px rgba(31,35,41,.05)' : 'none',
}}>
{entry.source_name.replace(/\s*Newsroom/, '')}
{!narrow && }
{!narrow && }
{!indexMode && entry.quality_score != null && }
{entry.title}
{!indexMode && (
{entry.summary}
)}
{!indexMode &&
{tags.slice(0, 3).map(t => (
{t.name}
))}
{entry.related > 0 && (
关联讨论 {entry.related} 条
)}
{entry.is_featured && }
}
);
};
// ---- axis row (timestamp + dot + line) wrapping a card ----
const AxisRow = ({ entry, last, children, compact = false }) => (
{clock(entry.published_at)}
{!last && }
{children}
);
// ---- states ----
const TLSkeleton = () => (
);
const Centered = ({ icon, title, hint, action }) => {
useLucide();
return (
{title}
{hint}
{action &&
{action}
}
);
};
const DemoMenu = ({ value, onPick, onClose }) => {
useLucide();
const items = [
{ v: null, l: '正常', icon: 'circle-check' },
{ v: 'loading', l: '加载中', icon: 'loader' },
{ v: 'empty', l: '空状态', icon: 'inbox' },
{ v: 'error', l: '加载失败', icon: 'cloud-off' },
];
return (
<>
状态演示
{items.map(it => {
const active = value === it.v || (it.v === null && !value);
return (
{ onPick(it.v); onClose(); }}
style={{ display: 'flex', alignItems: 'center', gap: 8, height: 32, padding: '0 10px', borderRadius: 6, cursor: 'pointer', fontSize: 13, color: active ? '#0F4DD1' : '#1F2329', background: active ? '#EBF1FE' : 'transparent' }}>
{it.l}
{active && }
);
})}
>
);
};
const TimelineView = ({
entries, status, view, scope, scopeLabel, tab, onTab,
density, onDensity, activeId, onOpen,
onRetry, onManage, onGoTimeline, counts, railOpen, onOpenRail, appRailWidth = 76, onDemo, demo, readerOpen = false,
}) => {
useLucide();
const [narrow, setNarrow] = React.useState(typeof window !== 'undefined' ? window.innerWidth < 760 : false);
const showQualitySwitch = counts.featured > 0 || tab === 'featured';
const contentMaxWidth = readerOpen ? 680 : 880;
const compactList = readerOpen || narrow;
React.useEffect(() => {
const onResize = () => setNarrow(window.innerWidth < 760);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// group by day
const groups = [];
entries.forEach(e => {
const label = dayLabel(e.published_at);
let g = groups.find(x => x.label === label);
if (!g) { g = { label, items: [] }; groups.push(g); }
g.items.push(e);
});
return (
{/* feed */}
{showQualitySwitch && (
)}
{status === 'loading' && Array.from({ length: 5 }).map((_, i) =>
)}
{status === 'error' &&
重试} />}
{status === 'empty' && 去订阅管理} />}
{status === 'ready' && entries.length === 0 && (
去订阅管理} />
)}
{status === 'ready' && groups.map((g, gi) => (
{/* date divider */}
{g.label}
{g.items.length} 条
{g.items.map((e, i) => (
))}
))}
{status === 'ready' && entries.length > 0 && (
没有更多了
)}
);
};
Object.assign(window, { TimelineView, Seg });