feat: 뉴스 링크 저장/조회 웹앱 추가
- Flask 기반 UI 및 /links API 구현 - 30개 단위 페이지네이션 + 무한 스크롤 적용 - 메타데이터(제목/요약/이미지) 추출 및 캐시 적용 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.pytest_cache/
|
||||
263
app.py
Normal file
263
app.py
Normal file
@@ -0,0 +1,263 @@
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import psycopg2
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from dotenv import load_dotenv
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__, static_folder="static", template_folder="templates")
|
||||
|
||||
|
||||
DEFAULT_DESCRIPTION = "설명 없음"
|
||||
DEFAULT_IMAGE = "/static/placeholder.svg"
|
||||
CACHE_TTL_SECONDS = int(os.getenv("CACHE_TTL_SECONDS", "3600"))
|
||||
FAILED_TTL_SECONDS = int(os.getenv("FAILED_TTL_SECONDS", "300"))
|
||||
METADATA_CACHE = {}
|
||||
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>"
|
||||
)
|
||||
DEFAULT_PAGE_SIZE = int(os.getenv("DEFAULT_PAGE_SIZE", "30"))
|
||||
MAX_PAGE_SIZE = int(os.getenv("MAX_PAGE_SIZE", "60"))
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
return psycopg2.connect(
|
||||
host=os.getenv("DB_HOST"),
|
||||
port=os.getenv("DB_PORT"),
|
||||
dbname=os.getenv("DB_NAME"),
|
||||
user=os.getenv("DB_USER"),
|
||||
password=os.getenv("DB_PASSWORD"),
|
||||
)
|
||||
|
||||
|
||||
def normalize_url(raw_url: str) -> str:
|
||||
if not raw_url:
|
||||
return raw_url
|
||||
parsed = urlparse(raw_url)
|
||||
if parsed.scheme:
|
||||
return raw_url
|
||||
return f"https://{raw_url}"
|
||||
|
||||
|
||||
def extract_meta(soup: BeautifulSoup, property_name: str, name: str):
|
||||
tag = soup.find("meta", property=property_name)
|
||||
if tag and tag.get("content"):
|
||||
return tag.get("content").strip()
|
||||
tag = soup.find("meta", attrs={"name": name})
|
||||
if tag and tag.get("content"):
|
||||
return tag.get("content").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def extract_fallback_description(soup: BeautifulSoup) -> str:
|
||||
for paragraph in soup.find_all("p"):
|
||||
text = paragraph.get_text(" ", strip=True)
|
||||
if len(text) >= 40:
|
||||
return text[:180]
|
||||
return ""
|
||||
|
||||
|
||||
def fetch_metadata(url: str):
|
||||
fallback = {
|
||||
"title": url,
|
||||
"description": DEFAULT_DESCRIPTION,
|
||||
"image": DEFAULT_IMAGE,
|
||||
}
|
||||
cached = METADATA_CACHE.get(url)
|
||||
now = time.time()
|
||||
if cached and cached["expires_at"] > now:
|
||||
return cached["data"]
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/121.0.0.0 Safari/537.36"
|
||||
)
|
||||
},
|
||||
timeout=6,
|
||||
)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
resolved_url = response.url or url
|
||||
|
||||
title = (
|
||||
extract_meta(soup, "og:title", "twitter:title")
|
||||
or extract_meta(soup, "twitter:title", "title")
|
||||
or (soup.title.string.strip() if soup.title and soup.title.string else "")
|
||||
or resolved_url
|
||||
)
|
||||
description = (
|
||||
extract_meta(soup, "og:description", "description")
|
||||
or extract_meta(soup, "twitter:description", "description")
|
||||
)
|
||||
if not description:
|
||||
description = extract_fallback_description(soup) or DEFAULT_DESCRIPTION
|
||||
image = (
|
||||
extract_meta(soup, "og:image", "twitter:image")
|
||||
or extract_meta(soup, "twitter:image", "image")
|
||||
or DEFAULT_IMAGE
|
||||
)
|
||||
data = {"title": title, "description": description, "image": image}
|
||||
METADATA_CACHE[url] = {
|
||||
"data": data,
|
||||
"expires_at": now + CACHE_TTL_SECONDS,
|
||||
"ok": True,
|
||||
}
|
||||
return data
|
||||
except Exception:
|
||||
METADATA_CACHE[url] = {
|
||||
"data": fallback,
|
||||
"expires_at": now + FAILED_TTL_SECONDS,
|
||||
"ok": False,
|
||||
}
|
||||
return fallback
|
||||
|
||||
|
||||
def _clamp_int(value, default: int, minimum: int, maximum: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except Exception:
|
||||
return default
|
||||
return max(minimum, min(parsed, maximum))
|
||||
|
||||
|
||||
def fetch_links_page_from_db(limit: int, offset: int):
|
||||
table = os.getenv("TABLE", "news_link")
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT id, url, created_at FROM {table} ORDER BY created_at DESC OFFSET %s LIMIT %s",
|
||||
(offset, limit),
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
links = []
|
||||
error_message = ""
|
||||
try:
|
||||
rows = fetch_links_page_from_db(DEFAULT_PAGE_SIZE, 0)
|
||||
for link_id, url, created_at in rows:
|
||||
links.append(
|
||||
{
|
||||
"id": link_id,
|
||||
"url": url,
|
||||
"created_at": created_at.isoformat()
|
||||
if isinstance(created_at, datetime)
|
||||
else str(created_at),
|
||||
"title": "",
|
||||
"description": "",
|
||||
"image": "",
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
error_message = f"DB 조회 실패: {exc}"
|
||||
return render_template(
|
||||
"index.html",
|
||||
links=links,
|
||||
error_message=error_message,
|
||||
placeholder_data_uri=PLACEHOLDER_DATA_URI,
|
||||
default_image=DEFAULT_IMAGE,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/links")
|
||||
def get_links():
|
||||
limit = _clamp_int(
|
||||
request.args.get("limit"), DEFAULT_PAGE_SIZE, minimum=1, maximum=MAX_PAGE_SIZE
|
||||
)
|
||||
offset = _clamp_int(request.args.get("offset"), 0, minimum=0, maximum=10_000_000)
|
||||
|
||||
try:
|
||||
rows_plus_one = fetch_links_page_from_db(limit + 1, offset)
|
||||
except Exception as exc:
|
||||
return jsonify({"error": "DB 조회 실패", "detail": str(exc)}), 500
|
||||
|
||||
has_more = len(rows_plus_one) > limit
|
||||
rows = rows_plus_one[:limit]
|
||||
|
||||
urls = [url for _, url, _ in rows]
|
||||
metas = []
|
||||
if urls:
|
||||
with ThreadPoolExecutor(max_workers=min(8, len(urls))) as executor:
|
||||
metas = list(executor.map(fetch_metadata, urls))
|
||||
|
||||
results = []
|
||||
for (link_id, url, created_at), meta in zip(rows, metas):
|
||||
results.append(
|
||||
{
|
||||
"id": link_id,
|
||||
"url": url,
|
||||
"created_at": created_at.isoformat()
|
||||
if isinstance(created_at, datetime)
|
||||
else str(created_at),
|
||||
**meta,
|
||||
}
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"items": results,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"next_offset": offset + len(results),
|
||||
"has_more": has_more,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/links")
|
||||
def add_link():
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_url = (data.get("url") or "").strip()
|
||||
if not raw_url:
|
||||
return jsonify({"error": "URL을 입력해주세요."}), 400
|
||||
|
||||
url = normalize_url(raw_url)
|
||||
table = os.getenv("TABLE", "news_link")
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"INSERT INTO {table} (url) VALUES (%s) RETURNING id, created_at",
|
||||
(url,),
|
||||
)
|
||||
link_id, created_at = cur.fetchone()
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
return jsonify({"error": "DB 저장 실패", "detail": str(exc)}), 500
|
||||
|
||||
meta = fetch_metadata(url)
|
||||
return jsonify(
|
||||
{
|
||||
"id": link_id,
|
||||
"url": url,
|
||||
"created_at": created_at.isoformat()
|
||||
if isinstance(created_at, datetime)
|
||||
else str(created_at),
|
||||
**meta,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8021, debug=True)
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Flask
|
||||
python-dotenv
|
||||
psycopg2-binary
|
||||
requests
|
||||
beautifulsoup4
|
||||
217
static/app.js
Normal file
217
static/app.js
Normal file
@@ -0,0 +1,217 @@
|
||||
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) {
|
||||
throw new Error(data.error || "링크를 불러오지 못했습니다.");
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new Error(data.error || "저장에 실패했습니다.");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
7
static/placeholder.svg
Normal file
7
static/placeholder.svg
Normal 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
21
static/styles.css
Normal 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;
|
||||
}
|
||||
127
templates/index.html
Normal file
127
templates/index.html
Normal file
@@ -0,0 +1,127 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>News Link</title>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="{{ url_for('static', filename='styles.css') }}" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container py-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">뉴스 링크</h1>
|
||||
<p class="text-secondary mb-0">저장한 링크의 요약을 한눈에 확인하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error_message %}
|
||||
<div class="alert alert-danger">{{ error_message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div
|
||||
id="emptyState"
|
||||
class="alert alert-light border d-none"
|
||||
>
|
||||
아직 저장된 링크가 없습니다.
|
||||
</div>
|
||||
|
||||
<div id="linkList" class="row g-4">
|
||||
{% for link in links %}
|
||||
{% set img_src = link.image if link.image and link.image != default_image else placeholder_data_uri %}
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<img
|
||||
src="{{ img_src }}"
|
||||
class="card-img-top"
|
||||
alt="미리보기 이미지"
|
||||
{% if link.image and link.image != default_image %}
|
||||
onerror="if(!this.dataset.fallbackApplied){this.dataset.fallbackApplied='1';this.onerror=null;this.src='{{ placeholder_data_uri }}';}"
|
||||
{% endif %}
|
||||
/>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h5 class="card-title mb-2">{{ link.title or link.url }}</h5>
|
||||
<p class="card-text text-secondary flex-grow-1">
|
||||
{{ link.description or "설명 없음" }}
|
||||
</p>
|
||||
<a
|
||||
href="{{ link.url }}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
>
|
||||
원문 보기
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="loading" class="text-center text-secondary py-4 d-none">
|
||||
불러오는 중...
|
||||
</div>
|
||||
<div id="scrollSentinel" style="height: 1px"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary rounded-circle shadow floating-btn"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#linkModal"
|
||||
aria-label="링크 추가"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="modal fade"
|
||||
id="linkModal"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="linkForm">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">링크 추가</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="modal"
|
||||
aria-label="Close"
|
||||
></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label for="urlInput" class="form-label">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
class="form-control"
|
||||
id="urlInput"
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
<div id="formError" class="text-danger small mt-2"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
data-bs-dismiss="modal"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user