const linkList = document.getElementById("linkList"); const emptyState = document.getElementById("emptyState"); const linkForm = document.getElementById("linkForm"); const urlInput = document.getElementById("urlInput"); const formError = document.getElementById("formError"); const loadingEl = document.getElementById("loading"); const scrollSentinel = document.getElementById("scrollSentinel"); const PLACEHOLDER_DATA_URI = "data:image/svg+xml;utf8," + "" + "" + "" + "" + "" + "No%20Image" + ""; function setLoading(isLoading) { if (!loadingEl) return; loadingEl.classList.toggle("d-none", !isLoading); } function extractFirstUrl(text) { if (!text) return ""; const trimmed = String(text).trim(); // 1) http/https URL const httpMatch = trimmed.match(/https?:\/\/[^\s<>"')\]]+/i); if (httpMatch && httpMatch[0]) return httpMatch[0]; // 2) www. URL (no scheme) const wwwMatch = trimmed.match(/www\.[^\s<>"')\]]+/i); if (wwwMatch && wwwMatch[0]) return `https://${wwwMatch[0]}`; return ""; } function setEmptyStateVisible(visible, message) { if (!emptyState) return; if (message) emptyState.textContent = message; emptyState.classList.toggle("d-none", !visible); } function appendLinks(links) { if (!links.length) return; setEmptyStateVisible(false); links.forEach((link) => { const col = document.createElement("div"); col.className = "col-12 col-md-6 col-lg-4"; const card = document.createElement("div"); card.className = "card h-100 shadow-sm"; const image = document.createElement("img"); image.className = "card-img-top"; image.alt = "미리보기 이미지"; const needsFallback = !link.image || link.image === "/static/placeholder.svg"; image.src = needsFallback ? PLACEHOLDER_DATA_URI : link.image; if (!needsFallback) { image.addEventListener("error", () => { if (image.dataset.fallbackApplied) { return; } image.dataset.fallbackApplied = "1"; image.onerror = null; image.src = PLACEHOLDER_DATA_URI; }); } const body = document.createElement("div"); body.className = "card-body d-flex flex-column"; const title = document.createElement("h5"); title.className = "card-title mb-2"; title.textContent = link.title || link.url; const description = document.createElement("p"); description.className = "card-text text-secondary flex-grow-1"; description.textContent = link.description || "설명 없음"; const linkBtn = document.createElement("a"); linkBtn.className = "btn btn-outline-primary btn-sm"; linkBtn.href = link.url; linkBtn.target = "_blank"; linkBtn.rel = "noopener"; linkBtn.textContent = "원문 보기"; body.append(title, description, linkBtn); card.append(image, body); col.appendChild(card); linkList.appendChild(col); }); } const PAGE_SIZE = 30; let nextOffset = 0; let hasMore = true; let loading = false; function resetPagination() { nextOffset = 0; hasMore = true; loading = false; linkList.innerHTML = ""; } async function loadNextPage() { if (loading || !hasMore) return; loading = true; setLoading(true); setEmptyStateVisible(false); try { const res = await fetch(`/links?limit=${PAGE_SIZE}&offset=${nextOffset}`); const data = await res.json(); if (!res.ok) { const detail = data?.detail ? ` (${data.detail})` : ""; throw new Error((data.error || "링크를 불러오지 못했습니다.") + detail); } const items = Array.isArray(data) ? data : data.items || []; appendLinks(items); if (Array.isArray(data)) { nextOffset += items.length; hasMore = items.length === PAGE_SIZE; } else { nextOffset = data.next_offset ?? nextOffset + items.length; hasMore = Boolean(data.has_more); } if (nextOffset === 0 && items.length === 0) { setEmptyStateVisible(true, "아직 저장된 링크가 없습니다."); } } catch (err) { if (nextOffset === 0) { setEmptyStateVisible(true, err.message); } hasMore = false; } finally { loading = false; setLoading(false); } } linkForm.addEventListener("submit", async (event) => { event.preventDefault(); formError.textContent = ""; const url = urlInput.value.trim(); if (!url) { formError.textContent = "URL을 입력해주세요."; return; } try { const res = await fetch("/links", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), }); const data = await res.json(); if (!res.ok) { const detail = data?.detail ? ` (${data.detail})` : ""; throw new Error((data.error || "저장에 실패했습니다.") + detail); } resetPagination(); await loadNextPage(); const modalElement = document.getElementById("linkModal"); const modal = bootstrap.Modal.getOrCreateInstance(modalElement); modal.hide(); urlInput.value = ""; } catch (err) { formError.textContent = err.message; } }); if (urlInput) { urlInput.addEventListener("paste", (event) => { const text = event.clipboardData?.getData("text") ?? ""; const extracted = extractFirstUrl(text); if (!extracted) return; event.preventDefault(); urlInput.value = extracted; urlInput.dispatchEvent(new Event("input", { bubbles: true })); }); } function setupInfiniteScroll() { if (!scrollSentinel) return; const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; if (entry && entry.isIntersecting) { loadNextPage(); } }, { root: null, rootMargin: "400px 0px", threshold: 0 } ); observer.observe(scrollSentinel); } function init() { resetPagination(); setupInfiniteScroll(); loadNextPage(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); }