const { useState, useEffect } = React; const API = ""; const PALETTE = ["#4F7CFF", "#9B6DFF", "#22A06B", "#F2994A", "#EB5757", "#8E8E93"]; // ?token=... 으로 최초 1회 진입 시 저장하고 URL에서 제거 (function captureToken() { const p = new URLSearchParams(location.search); const t = p.get("token"); if (t) { localStorage.setItem("token", t); history.replaceState({}, "", location.pathname); } })(); function authHeaders(extra) { const h = Object.assign({}, extra || {}); const t = localStorage.getItem("token"); if (t) h["Authorization"] = "Bearer " + t; return h; } async function getJSON(path) { const r = await fetch(path, { headers: authHeaders() }); if (!r.ok) throw new Error(path + " -> " + r.status); return r.json(); } async function postJSON(path, body) { const r = await fetch(path, { method: "POST", headers: authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(body), }); if (!r.ok) { let detail = r.status; try { detail = (await r.json()).detail || detail; } catch (e) {} throw new Error(String(detail)); } return r.json(); } function useLongPress(onLong, ms = 500) { const timer = React.useRef(null); const fired = React.useRef(false); const cb = React.useRef(onLong); cb.current = onLong; const start = () => { fired.current = false; timer.current = setTimeout(() => { fired.current = true; cb.current(); }, ms); }; const cancel = () => { if (timer.current) { clearTimeout(timer.current); timer.current = null; } }; return { onPointerDown: start, onPointerUp: cancel, onPointerLeave: cancel, onClickCapture: (e) => { if (fired.current) { e.preventDefault(); e.stopPropagation(); if (e.nativeEvent && e.nativeEvent.stopImmediatePropagation) e.nativeEvent.stopImmediatePropagation(); fired.current = false; } }, }; } function SiteIcon({ site, onOpen, onLongPress }) { const lp = useLongPress(() => onLongPress(site)); return ( ); } function urlBase64ToUint8Array(base64) { const pad = "=".repeat((4 - (base64.length % 4)) % 4); const b64 = (base64 + pad).replace(/-/g, "+").replace(/_/g, "/"); const raw = atob(b64); return Uint8Array.from([...raw].map((c) => c.charCodeAt(0))); } function App() { const [sites, setSites] = useState([]); const [postsBySite, setPostsBySite] = useState({}); const [bodyById, setBodyById] = useState({}); const [selectedId, setSelectedId] = useState(null); const [openPostId, setOpenPostId] = useState(null); const [shown, setShown] = useState(false); const [adding, setAdding] = useState(false); const [sheetSite, setSheetSite] = useState(null); const [pushOn, setPushOn] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); useEffect(() => { if (!("serviceWorker" in navigator)) return; navigator.serviceWorker.register("/service-worker.js").catch((e) => console.error(e)); // 이미 구독돼 있으면 "알림 켜기" 버튼 숨김(앱 재실행 시 재등장 방지) navigator.serviceWorker.ready .then((reg) => reg.pushManager.getSubscription()) .then((sub) => { if (sub) setPushOn(true); }) .catch((e) => console.error(e)); }, []); async function enablePush() { try { const perm = await Notification.requestPermission(); if (perm !== "granted") { window.alert("알림 권한이 거부되었습니다."); return; } const reg = await navigator.serviceWorker.ready; const { key } = await getJSON("/api/push/public-key"); if (!key) { window.alert("서버에 VAPID 키가 설정되지 않았습니다."); return; } const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(key), }); const j = sub.toJSON(); await postJSON("/api/push/subscribe", { endpoint: j.endpoint, keys: j.keys }); setPushOn(true); window.alert("알림이 켜졌습니다."); } catch (e) { console.error(e); window.alert("알림 설정 실패: " + e.message); } } function loadSites() { getJSON("/api/sites").then(setSites).catch((e) => console.error(e)); } useEffect(() => { loadSites(); }, []); const selected = sites.find((s) => s.id === selectedId) || null; const selectedPosts = selected ? postsBySite[selected.id] || [] : []; const postsLoaded = selected ? postsBySite[selected.id] !== undefined : false; const isReview = !!(selected && selected.auto_review); const visiblePosts = isReview ? selectedPosts.filter((p) => p.review_relevant !== false) : selectedPosts; useEffect(() => { if (selectedId == null) return; setShown(false); const t = requestAnimationFrame(() => setShown(true)); getJSON(`/api/sites/${selectedId}/posts`) .then((posts) => setPostsBySite((m) => ({ ...m, [selectedId]: posts }))) .catch((e) => console.error(e)); return () => cancelAnimationFrame(t); }, [selectedId]); async function openPost(siteId, postId) { if (openPostId === postId) { setOpenPostId(null); return; } setOpenPostId(postId); if (bodyById[postId] === undefined) { try { const post = await getJSON(`/api/posts/${postId}`); setBodyById((m) => ({ ...m, [postId]: post.body || "(본문 없음)" })); } catch (e) { console.error(e); setBodyById((m) => ({ ...m, [postId]: "(본문을 불러오지 못했습니다)" })); } } const wasUnread = selectedPosts.find((p) => p.id === postId && !p.is_read); if (wasUnread) { fetch(`/api/posts/${postId}/read`, { method: "POST", headers: authHeaders() }).catch((e) => console.error(e)); setPostsBySite((m) => ({ ...m, [siteId]: (m[siteId] || []).map((p) => (p.id === postId ? { ...p, is_read: true } : p)), })); setSites((prev) => prev.map((s) => (s.id === siteId ? { ...s, unread: Math.max(0, s.unread - 1) } : s))); } } async function deleteSite(siteId) { if (!window.confirm("이 사이트를 삭제할까요? 저장된 글도 함께 지워집니다.")) return; try { const r = await fetch(`/api/sites/${siteId}`, { method: "DELETE", headers: authHeaders() }); if (!r.ok) throw new Error(String(r.status)); setSelectedId(null); setSites((prev) => prev.filter((s) => s.id !== siteId)); setPostsBySite((m) => { const n = { ...m }; delete n[siteId]; return n; }); } catch (e) { console.error(e); } } async function renameSite(site) { const name = window.prompt("새 이름", site.name); if (!name || !name.trim()) return; try { const r = await fetch(`/api/sites/${site.id}`, { method: "PATCH", headers: authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ name: name.trim() }), }); if (!r.ok) throw new Error(String(r.status)); } catch (e) { console.error(e); window.alert("이름 변경 실패"); return; } setSheetSite(null); loadSites(); } async function togglePin(site) { try { const r = await fetch(`/api/sites/${site.id}`, { method: "PATCH", headers: authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ pinned: !site.pinned }), }); if (!r.ok) throw new Error(String(r.status)); } catch (e) { console.error(e); window.alert("고정 변경 실패"); return; } setSheetSite(null); loadSites(); } async function markAllRead(site) { try { const r = await fetch(`/api/sites/${site.id}/read-all`, { method: "POST", headers: authHeaders() }); if (!r.ok) throw new Error(String(r.status)); } catch (e) { console.error(e); window.alert("모두 읽음 처리 실패"); return; } setSheetSite(null); loadSites(); } async function toggleAutoReview(site) { try { const r = await fetch(`/api/sites/${site.id}`, { method: "PATCH", headers: authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ auto_review: !site.auto_review }), }); if (!r.ok) throw new Error(String(r.status)); } catch (e) { console.error(e); window.alert("자동검토 변경 실패"); return; } setSheetSite(null); loadSites(); } const totalNew = sites.reduce((n, s) => n + (s.unread || 0), 0); return (

새 소식

알림함

{totalNew > 0 && ( 안 읽은 글 {totalNew} )}
{!pushOn && ( )}
{/* 홈: 아이콘 그리드 + [+] */}
{sites.map((site) => ( { setSelectedId(id); setOpenPostId(null); }} onLongPress={(s) => setSheetSite(s)} /> ))} {/* [+] 추가 타일 */}

아이콘을 누르면 해당 사이트의 새 글이 펼쳐집니다.

{/* 사이트 추가 화면 */} {adding && setAdding(false)} onAdded={() => { setAdding(false); loadSites(); }} />} {settingsOpen && setSettingsOpen(false)} />} {/* 아이콘 long-press 액션시트 */} {sheetSite && (
setSheetSite(null)}>
e.stopPropagation()}>
{sheetSite.short} {sheetSite.name}
)} {/* 상세 보기 */} {selected && (
{selected.short}

{selected.name}

{selected.auto_review ? "🤖 AI가 관심사로 추려봄" : (selected.needs_login ? "🔒 로그인 연결됨" : "공개 피드")}

{!postsLoaded && (

글을 불러오는 중…

)} {postsLoaded && visiblePosts.length === 0 && (

{isReview ? "표시할 글이 없습니다." : ("글이 없습니다." + (selected.needs_login ? " 로그인이 필요한 게시판이면 ‹ 뒤로 → 삭제 후 자격증명으로 다시 추가하세요." : ""))}

)} {visiblePosts.map((post, i) => { const open = openPostId === post.id; return (
openPost(selected.id, post.id)} style={{ transitionDelay: shown ? `${i * 45}ms` : "0ms" }} className={"cursor-pointer rounded-2xl mb-2.5 px-4 py-3.5 border transition-all duration-300 ease-out " + (shown ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4") + " " + (open ? "bg-neutral-800/80 border-neutral-700" : post.is_read ? "bg-neutral-900/60 border-neutral-800/60" : "bg-neutral-900 border-neutral-700")}>
{!post.is_read && !open && ()}

{post.title}

{post.date_text}

{bodyById[post.id] === undefined ? "본문을 불러오는 중…" : bodyById[post.id]}

{!open && isReview && post.review_summary && (

{post.review_summary}

)} {!open && !isReview && post.author && (

{post.author}

)}
); })}
)}
); } function AddSite({ onClose, onAdded }) { const [url, setUrl] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [preview, setPreview] = useState(null); // {cms, detected_name, sample_posts} const [name, setName] = useState(""); const [short, setShort] = useState(""); const [color, setColor] = useState(PALETTE[0]); const [saving, setSaving] = useState(false); const [needsLogin, setNeedsLogin] = useState(false); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [useCookie, setUseCookie] = useState(false); const [cookie, setCookie] = useState(""); async function doPreview() { setLoading(true); setError(""); setPreview(null); const body = { url: url.trim() }; if (needsLogin) { if (useCookie) body.cookie = cookie.trim(); else { body.username = username.trim(); body.password = password; } } try { const p = await postJSON("/api/sites/preview", body); setPreview(p); setName(p.detected_name || ""); setShort((p.detected_name || "").slice(0, 4)); } catch (e) { setError(needsLogin && !useCookie ? "미리보기 실패: " + e.message + " — '쿠키 직접 입력'을 시도해보세요." : "미리보기 실패: " + e.message); } finally { setLoading(false); } } async function doSave() { if (!preview) return; setSaving(true); setError(""); const body = { name: name.trim(), short: short.trim() || name.trim().slice(0, 4), color, list_url: url.trim(), cms: preview.cms, needs_login: needsLogin }; if (needsLogin) { if (useCookie) body.cookie = cookie.trim(); else { body.username = username.trim(); body.password = password; } } try { await postJSON("/api/sites", body); onAdded(); } catch (e) { setError("저장 실패: " + e.message); setSaving(false); } } return (

사이트 추가

setUrl(e.target.value)} placeholder="https://…/artclList.do" className="w-full rounded-xl bg-neutral-900 border border-neutral-700 px-3 py-2.5 text-sm text-neutral-100 placeholder-neutral-600 focus:outline-none focus:border-neutral-500" /> {needsLogin && (
{!useCookie && ( <> setUsername(e.target.value)} placeholder="아이디" className="w-full rounded-lg bg-neutral-900 border border-neutral-700 px-3 py-2 text-sm text-neutral-100 placeholder-neutral-600 focus:outline-none focus:border-neutral-500" /> setPassword(e.target.value)} type="password" placeholder="비밀번호" className="w-full rounded-lg bg-neutral-900 border border-neutral-700 px-3 py-2 text-sm text-neutral-100 placeholder-neutral-600 focus:outline-none focus:border-neutral-500" /> )} {useCookie && (