220 lines
6.4 KiB
JavaScript
220 lines
6.4 KiB
JavaScript
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," +
|
|
"<svg%20width='640'%20height='360'%20viewBox='0%200%20640%20360'%20fill='none'%20" +
|
|
"xmlns='http://www.w3.org/2000/svg'>" +
|
|
"<rect%20width='640'%20height='360'%20fill='%23e9ecef'/>" +
|
|
"<rect%20x='120'%20y='90'%20width='400'%20height='180'%20rx='16'%20fill='%23dee2e6'/>" +
|
|
"<path%20d='M210%20210l60-70%2070%2080%2060-60%2090%2090H210z'%20fill='%23adb5bd'/>" +
|
|
"<circle%20cx='260'%20cy='150'%20r='22'%20fill='%23adb5bd'/>" +
|
|
"<text%20x='320'%20y='260'%20text-anchor='middle'%20font-size='18'%20" +
|
|
"fill='%236c757d'%20font-family='Arial,%20sans-serif'>No%20Image</text>" +
|
|
"</svg>";
|
|
|
|
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();
|
|
}
|