Initial commit after re-install

This commit is contained in:
2026-02-25 19:10:13 +09:00
commit 83dec7504e
12 changed files with 4799 additions and 0 deletions

219
static/app.js Normal file
View 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();
}

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

7
static/placeholder.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg width="640" height="360" viewBox="0 0 640 360" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="640" height="360" fill="#e9ecef"/>
<rect x="120" y="90" width="400" height="180" rx="16" fill="#dee2e6"/>
<path d="M210 210l60-70 70 80 60-60 90 90H210z" fill="#adb5bd"/>
<circle cx="260" cy="150" r="22" fill="#adb5bd"/>
<text x="320" y="260" text-anchor="middle" font-size="18" fill="#6c757d" font-family="Arial, sans-serif">No Image</text>
</svg>

After

Width:  |  Height:  |  Size: 472 B

21
static/styles.css Normal file
View File

@@ -0,0 +1,21 @@
body {
background: #f8f9fb;
}
.floating-btn {
position: fixed;
right: 24px;
bottom: 24px;
width: 56px;
height: 56px;
font-size: 32px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.card-img-top {
height: 180px;
object-fit: cover;
}