Files
ncuetalk_frontend/src/pages/AiNewsPage.jsx
dsyoon be8e7c9fd6 init
2025-12-27 14:50:12 +09:00

197 lines
7.3 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 [showNewsEditor, setShowNewsEditor] = useState(false);
const [newsUrl, setNewsUrl] = useState('');
const contentRef = useRef(null);
const sentinelRef = useRef(null);
useEffect(() => {
(async () => {
await loadNews(0, false);
// 최초 로드 후 최신 위치로 한 번 스크롤
requestAnimationFrame(() => {
const el = contentRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
})();
}, []);
// IntersectionObserver로 상단 도달 시 로드
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !newsLoading) {
loadNews(newsOffset, true);
}
},
{ root: contentRef.current, threshold: 0.1 }
);
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [newsOffset, newsLoading]);
const loadNews = async (offset = 0, prepend = false) => {
if (newsLoading) return;
setNewsLoading(true);
try {
const res = await fetch(`${API_BASE_URL}/community/ai_news?offset=${offset}&limit=10`);
if (!res.ok) return;
const data = await res.json();
const items = (data.items || []).reverse();
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 = data.nextOffset || 0;
setNewsOffset(nextOffset);
return nextOffset;
} 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' }}>
{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>
);
}