Initial commit after re-install
This commit is contained in:
219
static/app.js
Normal file
219
static/app.js
Normal file
@@ -0,0 +1,219 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user