Files
ncuetalk_frontend/src/pages/AiNewsPage.jsx
dsyoon 7b7e82873e init
2025-12-27 14:57:58 +09:00

239 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useRef, useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { API_BASE_URL } from '../config';
function normalizeExternalUrl(raw) {
if (!raw) return '';
const s = String(raw).trim();
if (!s) return '';
// already absolute
if (/^https?:\/\//i.test(s)) return s;
// protocol-relative
if (s.startsWith('//')) return `https:${s}`;
// common "missing scheme" cases from DB/meta
if (s.startsWith('img.youtube.com/')) return `https://${s}`;
if (s.startsWith('www.youtube.com/')) return `https://${s}`;
if (s.startsWith('youtube.com/')) return `https://${s}`;
return s; // leave as-is (could be relative path on same origin)
}
export default function AiNewsPage() {
const { user } = useAuth();
const [news, setNews] = useState([]);
const [newsOffset, setNewsOffset] = useState(0);
const [newsLoading, setNewsLoading] = useState(false);
const [newsHasMore, setNewsHasMore] = useState(true);
const [newsError, setNewsError] = useState('');
const [showNewsEditor, setShowNewsEditor] = useState(false);
const [newsUrl, setNewsUrl] = useState('');
const contentRef = useRef(null);
const sentinelRef = useRef(null);
useEffect(() => {
(async () => {
setNewsError('');
setNewsHasMore(true);
await loadNews(0, false);
// 최초 로드 후 최신 위치로 한 번 스크롤
requestAnimationFrame(() => {
const el = contentRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
})();
}, []);
// IntersectionObserver로 상단 도달 시 로드
useEffect(() => {
if (!newsHasMore || newsError) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !newsLoading && newsHasMore && !newsError) {
loadNews(newsOffset, true);
}
},
{ root: contentRef.current, threshold: 0.1 }
);
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [newsOffset, newsLoading, newsHasMore, newsError]);
const loadNews = async (offset = 0, prepend = false) => {
if (newsLoading || !newsHasMore || newsError) return;
setNewsLoading(true);
try {
const res = await fetch(`${API_BASE_URL}/community/ai_news?offset=${offset}&limit=10`);
if (!res.ok) {
// 404는 보통 Apache 프록시 미설정(정적서버가 /community 경로를 못 찾음)
setNewsError(`인사이트 데이터를 불러오지 못했습니다. (HTTP ${res.status})`);
setNewsHasMore(false);
return null;
}
const data = await res.json();
const rawItems = Array.isArray(data.items) ? data.items : [];
// 서버는 보통 최신이 먼저 오므로, UI를 "아래가 최신" 형태로 유지하려고 reverse 유지
const items = rawItems.slice().reverse();
// 더 이상 가져올 게 없으면 observer가 무한 호출하지 않도록 차단
if (rawItems.length === 0) {
setNewsHasMore(false);
}
if (prepend) {
const el = contentRef.current;
const prevH = el ? el.scrollHeight : document.documentElement.scrollHeight;
setNews((prev) => [...items, ...prev]);
setTimeout(() => {
if (el) {
el.scrollTop = el.scrollHeight - prevH;
} else {
const newH = document.documentElement.scrollHeight;
window.scrollTo(0, newH - prevH);
}
}, 0);
} else {
setNews(items);
setTimeout(() => {
const el = contentRef.current;
if (el) el.scrollTop = el.scrollHeight; else window.scrollTo(0, document.documentElement.scrollHeight);
}, 0);
}
const nextOffset = typeof data.nextOffset === 'number' ? data.nextOffset : null;
if (nextOffset === null || nextOffset === offset) {
setNewsHasMore(false);
} else {
setNewsOffset(nextOffset);
}
return nextOffset;
} catch (e) {
setNewsError('인사이트 데이터를 불러오지 못했습니다. (네트워크 오류)');
setNewsHasMore(false);
return null;
} finally {
setNewsLoading(false);
}
};
// 기존 스크롤 보정 루프 제거 IntersectionObserver 사용
const submitNews = async () => {
if (!newsUrl.trim()) return;
await fetch(`${API_BASE_URL}/community/ai_news`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: newsUrl, author_id: String(user?.user_id || ''), author_email: user?.email || null }),
});
setShowNewsEditor(false);
setNewsUrl('');
loadNews(0);
};
return (
<section className="page page-ai-news" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div className="section" style={{ paddingBottom: 8 }}>
<div className="section-head" style={{ marginBottom: 0 }}>
<h2 className="section-title">인사이트</h2>
<p className="section-desc">업계 동향/레퍼런스 링크를 모아두고, /고객과 빠르게 공유합니다.</p>
</div>
</div>
<main className="community-content" ref={contentRef} style={{ overflowY: 'auto', flex: 1 }}>
<div ref={sentinelRef} />
<div style={{ padding: '0 20px 50px 20px' }}>
{newsError ? (
<div className="card" style={{ marginBottom: 12, borderColor: '#ffd6c2' }}>
<div style={{ fontWeight: 700, marginBottom: 6 }}>인사이트 로딩 실패</div>
<div style={{ color: '#666', marginBottom: 10 }}>{newsError}</div>
<button
className="btn btn-primary"
onClick={() => {
setNewsError('');
setNewsHasMore(true);
setNewsOffset(0);
loadNews(0, false);
}}
>
다시 시도
</button>
</div>
) : null}
{news.map((n) => (
<div key={n.id} className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 12 }}>
{n.meta?.image ? (
<img
src={normalizeExternalUrl(n.meta.image)}
alt="thumb"
style={{ width: 96, height: 96, objectFit: 'cover', borderRadius: 8 }}
referrerPolicy="no-referrer"
/>
) : null}
<div style={{ flex: 1 }}>
<a
href={normalizeExternalUrl(n.meta?.url || n.url)}
target="_blank"
rel="noreferrer"
style={{ fontWeight: 600, color: '#1976d2', textDecoration: 'none' }}
>
{n.meta?.title || n.url}
</a>
<div style={{ marginTop: 6, color: '#555', lineHeight: 1.5 }}>{n.meta?.description}</div>
<div style={{ marginTop: 6, color: '#999', fontSize: '.9rem' }}>{new Date(n.created_at).toISOString().slice(0, 10)}</div>
</div>
</div>
</div>
))}
</div>
{newsLoading && (
<div className="loading-overlay">
<span className="loading-text">뉴스 로딩중</span>
<span className="loading-dots">
<span className="loading-dot"></span>
<span className="loading-dot"></span>
<span className="loading-dot"></span>
</span>
</div>
)}
{user && !showNewsEditor && (
<button
onClick={() => setShowNewsEditor(true)}
className="btn btn-primary"
style={{ position: 'fixed', right: 24, bottom: 24, zIndex: 1000 }}
>
뉴스등록
</button>
)}
{showNewsEditor && user && (
<div
style={{ position: 'fixed', right: 24, bottom: 84, zIndex: 1001, width: 'min(560px, calc(100vw - 40px))', background: '#fff', border: '1px solid #ffd6c2', borderRadius: 12, padding: 12, boxShadow: '0 6px 18px rgba(0,0,0,.15)' }}
>
<input
value={newsUrl}
onChange={(e) => setNewsUrl(e.target.value)}
placeholder="뉴스 URL을 입력하세요"
style={{ width: 'calc(100% - 25px)', marginBottom: 8, padding: '10px 12px', borderRadius: 8, border: '1px solid #ffd6c2' }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitNews();
}
}}
/>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={() => {
setShowNewsEditor(false);
setNewsUrl('');
}}
style={{ background: '#fff', border: '1px solid #ddd', padding: '8px 12px', borderRadius: 8 }}
>
취소
</button>
<button onClick={submitNews} style={{ background: '#ff9800', color: '#fff', border: 'none', padding: '8px 12px', borderRadius: 8 }}>등록</button>
</div>
</div>
)}
</main>
</section>
);
}