init
This commit is contained in:
19
README.md
19
README.md
@@ -83,10 +83,18 @@ npm run dev # 개발 서버 (http://localhost:5173)
|
|||||||
|
|
||||||
## 빌드 & 미리보기
|
## 빌드 & 미리보기
|
||||||
```bash
|
```bash
|
||||||
|
nvm use 20
|
||||||
|
npm ci # (처음/의존성 변경 시 권장) 이미 설치돼 있으면 npm install도 OK
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 재빌드
|
||||||
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
npm run preview # http://localhost:4173
|
npm run preview # http://localhost:4173
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## 프로덕션 배포 (Apache)
|
## 프로덕션 배포 (Apache)
|
||||||
이 프로젝트는 기본적으로 API 호출을 `API_BASE_URL` 기준으로 수행합니다.
|
이 프로젝트는 기본적으로 API 호출을 `API_BASE_URL` 기준으로 수행합니다.
|
||||||
|
|
||||||
@@ -138,6 +146,17 @@ sudo systemctl reload apache2
|
|||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 404로 무한 요청이 날 때(중요 체크)
|
||||||
|
브라우저 콘솔에 아래처럼 뜨면:
|
||||||
|
|
||||||
|
- `GET https://talk.ncue.net/community/ai_news ... 404`
|
||||||
|
|
||||||
|
대부분은 **Apache가 `/community`를 백엔드로 프록시하지 않고**, 프론트 정적 파일에서 해당 경로를 찾으려다 404를 내는 상황입니다.
|
||||||
|
|
||||||
|
- Apache 설정에 `ProxyPass /community ...`가 **:443 가상호스트에 실제로 적용**되었는지 확인
|
||||||
|
- `a2enmod proxy proxy_http` 적용 후 `systemctl reload apache2`
|
||||||
|
- SPA rewrite(.htaccess)와 함께 쓴다면, **프록시가 rewrite보다 먼저 적용**되도록 VirtualHost에 ProxyPass를 두는 것을 권장
|
||||||
|
|
||||||
백엔드 엔드포인트가 더 있다면 동일 패턴으로 `ProxyPass`를 추가하거나, `/api` prefix로 백엔드를 묶어 프론트 코드에서 `VITE_API_BASE_URL=/api`로 통일하는 것도 가능합니다.
|
백엔드 엔드포인트가 더 있다면 동일 패턴으로 `ProxyPass`를 추가하거나, `/api` prefix로 백엔드를 묶어 프론트 코드에서 `VITE_API_BASE_URL=/api`로 통일하는 것도 가능합니다.
|
||||||
|
|
||||||
## 빌드 산출물 구조
|
## 빌드 산출물 구조
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export default function AiNewsPage() {
|
|||||||
const [news, setNews] = useState([]);
|
const [news, setNews] = useState([]);
|
||||||
const [newsOffset, setNewsOffset] = useState(0);
|
const [newsOffset, setNewsOffset] = useState(0);
|
||||||
const [newsLoading, setNewsLoading] = useState(false);
|
const [newsLoading, setNewsLoading] = useState(false);
|
||||||
|
const [newsHasMore, setNewsHasMore] = useState(true);
|
||||||
|
const [newsError, setNewsError] = useState('');
|
||||||
const [showNewsEditor, setShowNewsEditor] = useState(false);
|
const [showNewsEditor, setShowNewsEditor] = useState(false);
|
||||||
const [newsUrl, setNewsUrl] = useState('');
|
const [newsUrl, setNewsUrl] = useState('');
|
||||||
const contentRef = useRef(null);
|
const contentRef = useRef(null);
|
||||||
@@ -29,6 +31,8 @@ export default function AiNewsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
setNewsError('');
|
||||||
|
setNewsHasMore(true);
|
||||||
await loadNews(0, false);
|
await loadNews(0, false);
|
||||||
// 최초 로드 후 최신 위치로 한 번 스크롤
|
// 최초 로드 후 최신 위치로 한 번 스크롤
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -40,9 +44,10 @@ export default function AiNewsPage() {
|
|||||||
|
|
||||||
// IntersectionObserver로 상단 도달 시 로드
|
// IntersectionObserver로 상단 도달 시 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!newsHasMore || newsError) return;
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries[0].isIntersecting && !newsLoading) {
|
if (entries[0].isIntersecting && !newsLoading && newsHasMore && !newsError) {
|
||||||
loadNews(newsOffset, true);
|
loadNews(newsOffset, true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -50,16 +55,28 @@ export default function AiNewsPage() {
|
|||||||
);
|
);
|
||||||
if (sentinelRef.current) observer.observe(sentinelRef.current);
|
if (sentinelRef.current) observer.observe(sentinelRef.current);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [newsOffset, newsLoading]);
|
}, [newsOffset, newsLoading, newsHasMore, newsError]);
|
||||||
|
|
||||||
const loadNews = async (offset = 0, prepend = false) => {
|
const loadNews = async (offset = 0, prepend = false) => {
|
||||||
if (newsLoading) return;
|
if (newsLoading || !newsHasMore || newsError) return;
|
||||||
setNewsLoading(true);
|
setNewsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/community/ai_news?offset=${offset}&limit=10`);
|
const res = await fetch(`${API_BASE_URL}/community/ai_news?offset=${offset}&limit=10`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) {
|
||||||
|
// 404는 보통 Apache 프록시 미설정(정적서버가 /community 경로를 못 찾음)
|
||||||
|
setNewsError(`인사이트 데이터를 불러오지 못했습니다. (HTTP ${res.status})`);
|
||||||
|
setNewsHasMore(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items = (data.items || []).reverse();
|
const rawItems = Array.isArray(data.items) ? data.items : [];
|
||||||
|
// 서버는 보통 최신이 먼저 오므로, UI를 "아래가 최신" 형태로 유지하려고 reverse 유지
|
||||||
|
const items = rawItems.slice().reverse();
|
||||||
|
|
||||||
|
// 더 이상 가져올 게 없으면 observer가 무한 호출하지 않도록 차단
|
||||||
|
if (rawItems.length === 0) {
|
||||||
|
setNewsHasMore(false);
|
||||||
|
}
|
||||||
if (prepend) {
|
if (prepend) {
|
||||||
const el = contentRef.current;
|
const el = contentRef.current;
|
||||||
const prevH = el ? el.scrollHeight : document.documentElement.scrollHeight;
|
const prevH = el ? el.scrollHeight : document.documentElement.scrollHeight;
|
||||||
@@ -79,9 +96,17 @@ export default function AiNewsPage() {
|
|||||||
if (el) el.scrollTop = el.scrollHeight; else window.scrollTo(0, document.documentElement.scrollHeight);
|
if (el) el.scrollTop = el.scrollHeight; else window.scrollTo(0, document.documentElement.scrollHeight);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
const nextOffset = data.nextOffset || 0;
|
const nextOffset = typeof data.nextOffset === 'number' ? data.nextOffset : null;
|
||||||
setNewsOffset(nextOffset);
|
if (nextOffset === null || nextOffset === offset) {
|
||||||
|
setNewsHasMore(false);
|
||||||
|
} else {
|
||||||
|
setNewsOffset(nextOffset);
|
||||||
|
}
|
||||||
return nextOffset;
|
return nextOffset;
|
||||||
|
} catch (e) {
|
||||||
|
setNewsError('인사이트 데이터를 불러오지 못했습니다. (네트워크 오류)');
|
||||||
|
setNewsHasMore(false);
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setNewsLoading(false);
|
setNewsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -112,6 +137,23 @@ export default function AiNewsPage() {
|
|||||||
<main className="community-content" ref={contentRef} style={{ overflowY: 'auto', flex: 1 }}>
|
<main className="community-content" ref={contentRef} style={{ overflowY: 'auto', flex: 1 }}>
|
||||||
<div ref={sentinelRef} />
|
<div ref={sentinelRef} />
|
||||||
<div style={{ padding: '0 20px 50px 20px' }}>
|
<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) => (
|
{news.map((n) => (
|
||||||
<div key={n.id} className="card" style={{ marginBottom: 12 }}>
|
<div key={n.id} className="card" style={{ marginBottom: 12 }}>
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user