239 lines
9.2 KiB
JavaScript
239 lines
9.2 KiB
JavaScript
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>
|
||
);
|
||
}
|
||
|
||
|