Initial commit after re-install
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/
|
||||||
40
PROMPT.txt
Normal file
40
PROMPT.txt
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
뉴스 링크를 깔끔하게 관리하고 요약까지 보여주는 웹 서비스라니, '나중에 읽어야지' 하고 쌓아두기만 하는 우리 모두의 습관을 아주 우아하게 해결하려는 멋진 프로젝트입니다.
|
||||||
|
|
||||||
|
단순히 URL만 저장된 DB에서 정보를 가져와 화면에 뿌려주려면, URL에서 제목과 요약(메타데이터)을 추출하는 '크롤링/파싱' 로직이 핵심입니다.
|
||||||
|
|
||||||
|
DB 연결: ./env 파일에 저장된 환경 변수를 참조해서 연결해줘. (DB_HOST, DB_USER, DB_PASS 등 포함)
|
||||||
|
|
||||||
|
DB 테이블: news_link 테이블에는 id, url, created_at 컬럼이 있어.
|
||||||
|
|
||||||
|
핵심 기능: 사용자가 URL을 입력하면 해당 페이지의 메타데이터(Title, Description, Image)를 추출해서 보여줘야 해.
|
||||||
|
|
||||||
|
먼저 프로젝트 기본 구조와 ./env 파일을 읽어 DB에 연결하는 설정 코드를 작성해줘."
|
||||||
|
|
||||||
|
2. 백엔드: 데이터 추출 및 API 로직 프롬프트
|
||||||
|
URL만 있는 상태에서 글의 앞부분(소개)을 가져오려면 Open Graph 데이터를 활용하는 것이 가장 효율적입니다.
|
||||||
|
|
||||||
|
프롬프트: "백엔드에서 다음 API 엔드포인트를 만들어줘.
|
||||||
|
|
||||||
|
GET /links: DB에서 모든 URL을 가져온 뒤, 각 URL의 웹사이트를 방문해 메타데이터(제목, 요약 설명, 대표 이미지)를 추출해서 JSON 형태로 반환해줘. (힌트: cheerio나 metascraper 같은 라이브러리 활용)
|
||||||
|
|
||||||
|
POST /links: 사용자가 입력한 새로운 URL을 news_link 테이블에 저장해줘.
|
||||||
|
|
||||||
|
특히 URL 크롤링 시 에러가 발생하면 기본 이미지와 '설명 없음' 메시지를 반환하도록 예외 처리를 꼼꼼하게 해줘."
|
||||||
|
|
||||||
|
3. 프론트엔드: UI 및 인터페이스 프롬프트
|
||||||
|
사용자가 보기 편한 카드 레이아웃과 추가 버튼을 만드는 단계입니다.
|
||||||
|
|
||||||
|
프롬프트: "사용자에게 보여줄 프론트엔드 화면을 디자인해줘.
|
||||||
|
|
||||||
|
전체 레이아웃: 깔끔한 카드 스타일의 리스트 뷰. 각 카드에는 뉴스 제목, 간략한 소개글(요약), 해당 사이트로 이동하는 링크가 포함돼야 해.
|
||||||
|
|
||||||
|
추가 기능: 화면 오른쪽 하단에 '플로팅 버튼(+)'을 만들고, 클릭하면 URL을 입력할 수 있는 모달(Modal) 창이 뜨게 해줘.
|
||||||
|
|
||||||
|
상태 관리: URL을 추가하면 페이지 새로고침 없이 리스트에 바로 반영되도록 해줘.
|
||||||
|
|
||||||
|
현대적이고 심플한 UI를 위해 **[Tailwind CSS 혹은 Bootstrap]**을 사용해서 코드를 짜줘."
|
||||||
|
|
||||||
|
💡 구현 시 참고할 팁
|
||||||
|
성능 최적화: 매번 링크를 불러올 때마다 실시간으로 크롤링하면 속도가 매우 느려집니다. 처음 URL을 추가할 때 DB에 제목과 요약 내용을 함께 저장(save)하는 방식이 훨씬 쾌적합니다.
|
||||||
|
|
||||||
|
Open Graph: 대부분의 뉴스나 블로그는 <meta property="og:description"> 태그를 가지고 있습니다. 이 부분을 긁어오면 말씀하신 "앞부분 소개"를 가장 정확하게 가져올 수 있습니다.
|
||||||
102
README.md
Normal file
102
README.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# News Link
|
||||||
|
|
||||||
|
뉴스/아티클 URL을 저장하고, 제목/요약/대표 이미지를 추출해 카드 형태로 보여주는 Flask 웹앱입니다.
|
||||||
|
|
||||||
|
## 구성
|
||||||
|
|
||||||
|
- `app.py`: Flask 서버, DB 연결, 메타데이터 추출, API
|
||||||
|
- `templates/index.html`: 초기 화면(SSR) + 모달 UI
|
||||||
|
- `static/app.js`: 무한 스크롤(30개 단위), URL 붙여넣기 파싱, 추가/갱신
|
||||||
|
- `static/styles.css`: 스타일
|
||||||
|
- `static/placeholder.svg`: 이미지 없을 때 표시용
|
||||||
|
|
||||||
|
## 요구사항
|
||||||
|
|
||||||
|
- Python 3.x
|
||||||
|
- PostgreSQL
|
||||||
|
- (권장) 미니콘다 환경: `ncue`
|
||||||
|
|
||||||
|
## 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 환경변수(.env)
|
||||||
|
|
||||||
|
프로젝트 루트에 `.env` 파일을 만들고 아래 값을 설정하세요. (`.env`는 git에 커밋되지 않습니다)
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
DB_HOST=...
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=...
|
||||||
|
DB_USER=...
|
||||||
|
DB_PASSWORD=...
|
||||||
|
TABLE=news_link
|
||||||
|
```
|
||||||
|
|
||||||
|
옵션(선택):
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
PORT=8021
|
||||||
|
DB_SCHEMA=public
|
||||||
|
DEFAULT_AUTHOR_ID=1
|
||||||
|
DEFAULT_PAGE_SIZE=30
|
||||||
|
MAX_PAGE_SIZE=60
|
||||||
|
CACHE_TTL_SECONDS=3600
|
||||||
|
FAILED_TTL_SECONDS=300
|
||||||
|
```
|
||||||
|
|
||||||
|
## DB 스키마
|
||||||
|
|
||||||
|
`TABLE`(기본 `news_link`) 테이블에 아래 컬럼이 필요합니다.
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `url`
|
||||||
|
- `created_at`
|
||||||
|
|
||||||
|
예시(참고):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS news_link (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
기본 접속 주소:
|
||||||
|
|
||||||
|
- `http://localhost:8021`
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET /links?limit=30&offset=0`
|
||||||
|
- 30개 단위로 링크를 가져옵니다(프론트 무한 스크롤).
|
||||||
|
- 응답:
|
||||||
|
- `items`: 링크 배열
|
||||||
|
- `next_offset`: 다음 요청 offset
|
||||||
|
- `has_more`: 다음 페이지 존재 여부
|
||||||
|
- `POST /links`
|
||||||
|
- body: `{ "url": "https://..." }`
|
||||||
|
- URL을 DB에 저장하고 메타데이터를 추출해 반환합니다.
|
||||||
|
|
||||||
|
## 동작 메모
|
||||||
|
|
||||||
|
- 초기 화면은 DB에서 **첫 페이지(기본 30개)** 만 가져와 SSR로 즉시 렌더링합니다.
|
||||||
|
- 메타데이터 추출은 캐시를 사용합니다(성공/실패 TTL 각각 적용).
|
||||||
|
|
||||||
|
## ncue.net 연동 (ref 전달)
|
||||||
|
|
||||||
|
`ncue.net`의 `/go`에서 아래와 같이 전달되는 값을 받아 `author_id(text)`에 저장합니다.
|
||||||
|
|
||||||
|
- 로그인 사용자: `ref_type=email&ref=<email>`
|
||||||
|
- 비로그인 사용자: `ref_type=ip&ref=<ip>`
|
||||||
|
|
||||||
|
이 값은 최초 진입 시 쿼리스트링으로 들어오며, 이후 요청에서도 유지되도록 서버가 쿠키로 저장합니다.
|
||||||
467
app.py
Normal file
467
app.py
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
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, make_response, render_template, request, send_from_directory
|
||||||
|
|
||||||
|
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 = {}
|
||||||
|
TABLE_COLUMNS_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"))
|
||||||
|
DEFAULT_SCHEMA = os.getenv("DB_SCHEMA", "public")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_identifier(name: str) -> str:
|
||||||
|
if not name or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
|
||||||
|
raise ValueError(f"Invalid SQL identifier: {name!r}")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _table_ref(schema: str, table: str) -> str:
|
||||||
|
return f"{_safe_identifier(schema)}.{_safe_identifier(table)}"
|
||||||
|
|
||||||
|
|
||||||
|
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 get_table_columns(schema: str, table: str):
|
||||||
|
key = (schema, table)
|
||||||
|
cached = TABLE_COLUMNS_CACHE.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
with get_db_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = %s AND table_name = %s
|
||||||
|
""",
|
||||||
|
(schema, table),
|
||||||
|
)
|
||||||
|
cols = {row[0] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
TABLE_COLUMNS_CACHE[key] = cols
|
||||||
|
return cols
|
||||||
|
|
||||||
|
|
||||||
|
def get_table_columns_info(schema: str, table: str):
|
||||||
|
key = ("info", schema, table)
|
||||||
|
cached = TABLE_COLUMNS_CACHE.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
with get_db_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name, data_type, udt_name, is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = %s AND table_name = %s
|
||||||
|
""",
|
||||||
|
(schema, table),
|
||||||
|
)
|
||||||
|
info = {
|
||||||
|
row[0]: {
|
||||||
|
"data_type": row[1],
|
||||||
|
"udt_name": row[2],
|
||||||
|
"is_nullable": row[3],
|
||||||
|
}
|
||||||
|
for row in cur.fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
TABLE_COLUMNS_CACHE[key] = info
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_identity(req) -> tuple[str | None, str | None]:
|
||||||
|
"""
|
||||||
|
가능한 경우 (email, ip)를 반환.
|
||||||
|
- 이메일: 프록시/SSO가 주입하는 헤더에서 추출
|
||||||
|
- IP: X-Forwarded-For / X-Real-IP / remote_addr 순
|
||||||
|
"""
|
||||||
|
# 0) ncue.net/go 연동: ref_type/ref 를 쿼리스트링 또는 쿠키로 전달받을 수 있음
|
||||||
|
ref_type = (req.args.get("ref_type") or req.cookies.get("ref_type") or "").strip()
|
||||||
|
ref = (req.args.get("ref") or req.cookies.get("ref") or "").strip()
|
||||||
|
if ref_type in ("email", "ip") and ref:
|
||||||
|
if ref_type == "email":
|
||||||
|
return ref, None
|
||||||
|
return None, ref
|
||||||
|
|
||||||
|
email_headers = [
|
||||||
|
"X-User-Email",
|
||||||
|
"X-Forwarded-Email",
|
||||||
|
"X-Auth-Request-Email",
|
||||||
|
"X-Forwarded-User",
|
||||||
|
"Remote-User",
|
||||||
|
"X-Email",
|
||||||
|
]
|
||||||
|
email = None
|
||||||
|
for h in email_headers:
|
||||||
|
v = (req.headers.get(h) or "").strip()
|
||||||
|
if v and "@" in v:
|
||||||
|
email = v
|
||||||
|
break
|
||||||
|
|
||||||
|
xff = (req.headers.get("X-Forwarded-For") or "").strip()
|
||||||
|
if xff:
|
||||||
|
ip = xff.split(",")[0].strip()
|
||||||
|
else:
|
||||||
|
ip = (req.headers.get("X-Real-IP") or "").strip() or (req.remote_addr or "")
|
||||||
|
ip = ip.strip() or None
|
||||||
|
return email, ip
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_set_ref_cookies(resp):
|
||||||
|
ref_type = (request.args.get("ref_type") or "").strip()
|
||||||
|
ref = (request.args.get("ref") or "").strip()
|
||||||
|
if ref_type in ("email", "ip") and ref:
|
||||||
|
# JS 요청(/links)에서도 유지되도록 쿠키 저장 (SameSite=Lax)
|
||||||
|
max_age = 60 * 60 * 24 * 30 # 30일
|
||||||
|
resp.set_cookie("ref_type", ref_type, max_age=max_age, samesite="Lax")
|
||||||
|
resp.set_cookie("ref", ref, max_age=max_age, samesite="Lax")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_links_page_from_db(limit: int, offset: int):
|
||||||
|
table = os.getenv("TABLE", "news_link")
|
||||||
|
schema = os.getenv("DB_SCHEMA", DEFAULT_SCHEMA)
|
||||||
|
table_ref = _table_ref(schema, table)
|
||||||
|
with get_db_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT id, url, created_at FROM {table_ref} 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}"
|
||||||
|
resp = make_response(
|
||||||
|
render_template(
|
||||||
|
"index.html",
|
||||||
|
links=links,
|
||||||
|
error_message=error_message,
|
||||||
|
placeholder_data_uri=PLACEHOLDER_DATA_URI,
|
||||||
|
default_image=DEFAULT_IMAGE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _maybe_set_ref_cookies(resp)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/favicon.ico")
|
||||||
|
def favicon():
|
||||||
|
# ncue.net 등에서 /favicon.ico 로 직접 가져갈 수 있게 제공
|
||||||
|
return send_from_directory(
|
||||||
|
app.static_folder,
|
||||||
|
"favicon.ico",
|
||||||
|
mimetype="image/x-icon",
|
||||||
|
max_age=60 * 60 * 24 * 7,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
||||||
|
schema = os.getenv("DB_SCHEMA", DEFAULT_SCHEMA)
|
||||||
|
table_ref = _table_ref(schema, table)
|
||||||
|
try:
|
||||||
|
cols = get_table_columns(schema, table)
|
||||||
|
cols_info = get_table_columns_info(schema, table)
|
||||||
|
email, ip = get_request_identity(request)
|
||||||
|
identity = email or ip # 이메일 우선, 없으면 IP
|
||||||
|
|
||||||
|
insert_cols = ["url"]
|
||||||
|
insert_vals_sql = ["%s"]
|
||||||
|
insert_params = [url]
|
||||||
|
|
||||||
|
# 운영 DB 스키마 호환: created_at/updated_at, author_id 등이 NOT NULL일 수 있음
|
||||||
|
if "created_at" in cols:
|
||||||
|
insert_cols.append("created_at")
|
||||||
|
insert_vals_sql.append("NOW()")
|
||||||
|
if "updated_at" in cols:
|
||||||
|
insert_cols.append("updated_at")
|
||||||
|
insert_vals_sql.append("NOW()")
|
||||||
|
|
||||||
|
if "author_id" in cols:
|
||||||
|
author_col = cols_info.get("author_id", {})
|
||||||
|
data_type = (author_col.get("data_type") or "").lower()
|
||||||
|
udt = (author_col.get("udt_name") or "").lower()
|
||||||
|
|
||||||
|
# 1) author_id가 텍스트 계열이면: 이메일/아이피 문자열을 그대로 저장
|
||||||
|
if data_type in ("text", "character varying", "character"):
|
||||||
|
insert_cols.append("author_id")
|
||||||
|
insert_vals_sql.append("%s")
|
||||||
|
insert_params.append(identity or "unknown")
|
||||||
|
|
||||||
|
# 2) author_id가 숫자(정수/숫자)면: 문자열 저장 불가
|
||||||
|
# → 기존 DEFAULT_AUTHOR_ID로 채우고, 가능한 경우 author_email/author_ip에 따로 저장(스키마 호환)
|
||||||
|
elif udt in ("int2", "int4", "int8") or data_type in ("smallint", "integer", "bigint", "numeric"):
|
||||||
|
raw_author_id = os.getenv("DEFAULT_AUTHOR_ID")
|
||||||
|
if raw_author_id is None or str(raw_author_id).strip() == "":
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"error": "DB 저장 실패",
|
||||||
|
"detail": "author_id가 정수 NOT NULL입니다. .env에 DEFAULT_AUTHOR_ID(정수)를 설정하거나, author_id 타입을 text로 변경하세요.",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
author_id_int = int(raw_author_id)
|
||||||
|
except Exception:
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"error": "DB 저장 실패",
|
||||||
|
"detail": f"DEFAULT_AUTHOR_ID는 정수여야 합니다: {raw_author_id!r}",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
insert_cols.append("author_id")
|
||||||
|
insert_vals_sql.append("%s")
|
||||||
|
insert_params.append(author_id_int)
|
||||||
|
|
||||||
|
if "author_email" in cols and email:
|
||||||
|
insert_cols.append("author_email")
|
||||||
|
insert_vals_sql.append("%s")
|
||||||
|
insert_params.append(email)
|
||||||
|
if "author_ip" in cols and ip:
|
||||||
|
insert_cols.append("author_ip")
|
||||||
|
insert_vals_sql.append("%s")
|
||||||
|
insert_params.append(ip)
|
||||||
|
|
||||||
|
# 3) 기타 타입(uuid 등): 우선 문자열을 넣되 실패 시 detail로 노출
|
||||||
|
else:
|
||||||
|
insert_cols.append("author_id")
|
||||||
|
insert_vals_sql.append("%s")
|
||||||
|
insert_params.append(identity or "unknown")
|
||||||
|
|
||||||
|
with get_db_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"INSERT INTO {table_ref} ({', '.join(insert_cols)}) VALUES ({', '.join(insert_vals_sql)}) RETURNING id, created_at",
|
||||||
|
tuple(insert_params),
|
||||||
|
)
|
||||||
|
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
|
||||||
14
run.sh
Executable file
14
run.sh
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd /home/dsyoon/workspace/news_link
|
||||||
|
|
||||||
|
PORT="${PORT:-8021}"
|
||||||
|
|
||||||
|
if lsof -ti tcp:"${PORT}" >/dev/null 2>&1; then
|
||||||
|
echo "Stopping existing server on port ${PORT}..."
|
||||||
|
lsof -ti tcp:"${PORT}" | xargs -r kill -9
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
nohup /home/dsyoon/workspace/miniconda3/envs/ncue/bin/python app.py > nohup.out &
|
||||||
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();
|
||||||
|
}
|
||||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
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;
|
||||||
|
}
|
||||||
132
templates/index.html
Normal file
132
templates/index.html
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<!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
|
||||||
|
rel="icon"
|
||||||
|
href="{{ url_for('static', filename='favicon.ico') }}"
|
||||||
|
type="image/x-icon"
|
||||||
|
/>
|
||||||
|
<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