This commit is contained in:
dsyoon
2025-12-27 14:07:27 +09:00
parent 976191d314
commit 58606b7eab
35 changed files with 5133 additions and 1 deletions

171
src/pages/AiNewsPage.jsx Normal file
View File

@@ -0,0 +1,171 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { API_BASE_URL } from '../config';
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={n.meta.image} alt="thumb" style={{ width: 96, height: 96, objectFit: 'cover', borderRadius: 8 }} />
) : null}
<div style={{ flex: 1 }}>
<a href={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>
);
}