diff --git a/app/main.py b/app/main.py index ca95709..a26fa4b 100644 --- a/app/main.py +++ b/app/main.py @@ -11,6 +11,8 @@ from app.database import engine from app.models import init_db from app.routes.admin import router as admin_router from app.routes.pages import router as pages_router +from app.routes.search import router as search_router +from app.routes.user import router as user_router logging.basicConfig( level=logging.DEBUG if settings.APP_DEBUG else logging.INFO, @@ -43,6 +45,8 @@ def create_app() -> FastAPI: # 路由 app.include_router(pages_router) app.include_router(admin_router) + app.include_router(search_router) + app.include_router(user_router) return app diff --git a/app/routes/search.py b/app/routes/search.py new file mode 100644 index 0000000..ab9e59d --- /dev/null +++ b/app/routes/search.py @@ -0,0 +1,249 @@ +"""搜索、阅读列表、RSS Feed 路由。""" + +from __future__ import annotations + +import math +from datetime import date, datetime, timedelta, timezone +from zoneinfo import ZoneInfo +from xml.sax.saxutils import escape + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import Response +from fastapi.templating import Jinja2Templates +from sqlalchemy import text +from sqlalchemy.orm import Session, joinedload + +from app.config import settings +from app.database import get_db +from app.models import Paper, PaperTag, UserReadingStatus +from app.services.searcher import get_all_tags, search_papers + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +# ── 搜索页 ──────────────────────────────────────────────────────────── + + +@router.get("/search") +def search_page( + request: Request, + q: str = Query(default=""), + tag: str = Query(default=""), + sort: str = Query(default="relevance"), + page: int = Query(default=1, ge=1), + db: Session = Depends(get_db), +): + """搜索页面。""" + result = search_papers(db, query=q or None, tag=tag or None, sort=sort, page=page) + all_tags = get_all_tags(db) + + return templates.TemplateResponse( + request, + "search.html", + { + "query": q, + "tag": tag, + "sort": sort, + "results": result["results"], + "snippets": result["snippets"], + "total": result["total"], + "page": result["page"], + "total_pages": result["total_pages"], + "all_tags": all_tags, + "page_title": f"搜索: {q}" if q else "搜索", + "today": _today_str(), + }, + ) + + +# ── 搜索 JSON API ───────────────────────────────────────────────────── + + +@router.get("/api/search") +def search_api( + q: str = Query(default=""), + tag: str = Query(default=""), + sort: str = Query(default="relevance"), + page: int = Query(default=1, ge=1), + db: Session = Depends(get_db), +): + """搜索 JSON API。""" + result = search_papers(db, query=q or None, tag=tag or None, sort=sort, page=page) + + items = [] + for paper in result["results"]: + snippet = result["snippets"].get(paper.id, {}) + items.append( + { + "arxiv_id": paper.arxiv_id, + "title_en": paper.title_en, + "title_zh": paper.title_zh, + "paper_date": paper.paper_date.isoformat() if paper.paper_date else None, + "upvotes": paper.upvotes, + "tags": [t.tag for t in paper.tags], + "authors": [a.name for a in paper.authors], + "snippet_title_zh": snippet.get("title_zh"), + "snippet_abstract": snippet.get("abstract"), + } + ) + + return { + "results": items, + "total": result["total"], + "page": result["page"], + "total_pages": result["total_pages"], + } + + +# ── 阅读列表 ────────────────────────────────────────────────────────── + + +@router.get("/reading-list") +def reading_list_page( + request: Request, + filter: str = Query(default="all"), + tag: str = Query(default=""), + db: Session = Depends(get_db), +): + """阅读列表页面。""" + papers = _query_reading_list(db, filter, tag or None) + all_tags = get_all_tags(db) + + return templates.TemplateResponse( + request, + "reading_list.html", + { + "papers": papers, + "current_filter": filter, + "current_tag": tag, + "all_tags": all_tags, + "page_title": "阅读列表", + "today": _today_str(), + }, + ) + + +def _query_reading_list( + db: Session, + filter_type: str, + tag: str | None, +) -> list[Paper]: + """根据筛选条件查询阅读列表。""" + from sqlalchemy import or_ + + # 基础:有任意用户数据的论文 + base = db.query(Paper).filter( + or_( + Paper.bookmark.has(), + Paper.reading_status.has(), + Paper.note.has(), + ) + ) + + # 应用筛选 + if filter_type == "has_note": + base = base.filter(Paper.note.has()) + elif filter_type in ("unread", "skimmed", "read_summary", "read_full"): + base = base.filter( + Paper.reading_status.has(UserReadingStatus.status == filter_type) + ) + + # 应用标签 + if tag: + base = base.filter(Paper.tags.any(PaperTag.tag == tag)) + + return ( + base.options( + joinedload(Paper.authors), + joinedload(Paper.tags), + joinedload(Paper.summary_status), + joinedload(Paper.bookmark), + joinedload(Paper.reading_status), + joinedload(Paper.note), + ) + .order_by(Paper.paper_date.desc(), Paper.upvotes.desc()) + .all() + ) + + +# ── RSS Feed ────────────────────────────────────────────────────────── + + +@router.get("/rss.xml") +def rss_feed( + tag: str = Query(default=""), + db: Session = Depends(get_db), +): + """RSS 2.0 Feed — 最近 7 天论文。""" + seven_days_ago = date.today() - timedelta(days=7) + + query = ( + db.query(Paper) + .filter(Paper.paper_date >= seven_days_ago) + .options( + joinedload(Paper.authors), + joinedload(Paper.tags), + joinedload(Paper.summary), + ) + .order_by(Paper.paper_date.desc(), Paper.upvotes.desc()) + ) + + if tag: + query = query.filter(Paper.tags.any(PaperTag.tag == tag)) + + papers = query.all() + xml = _generate_rss_xml(papers, settings.BASE_URL, tag or None) + return Response(content=xml, media_type="application/xml") + + +def _generate_rss_xml(papers: list[Paper], base_url: str, tag: str | None) -> str: + """生成 RSS 2.0 XML。""" + lines = [''] + lines.append('') + lines.append(" ") + + channel_title = "HF Daily Papers" + if tag: + channel_title += f" — {tag}" + lines.append(f" {escape(channel_title)}") + lines.append(f" {escape(base_url)}") + lines.append(" HuggingFace Daily Papers — 中文论文导览站") + lines.append(f" zh-CN") + + for paper in papers: + title_text = paper.title_zh or paper.title_en + link = f"{base_url}/paper/{paper.arxiv_id}" + + desc = "" + if paper.summary and paper.summary.one_line: + desc = paper.summary.one_line + elif paper.abstract: + desc = paper.abstract[:500] + + pub_date = "" + if paper.paper_date: + # RFC 822 格式 + pub_date = paper.paper_date.strftime("%a, %d %b %Y 00:00:00 +0800") + + lines.append(" ") + lines.append(f" {escape(title_text)}") + lines.append(f" {escape(link)}") + lines.append(f" {escape(desc)}") + if pub_date: + lines.append(f" {pub_date}") + lines.append(f" {escape(link)}") + lines.append(" ") + + lines.append(" ") + lines.append("") + return "\n".join(lines) + + +# ── 工具函数 ────────────────────────────────────────────────────────── + + +def _today_str() -> str: + """当前日期字符串(按 APP_TIMEZONE)。""" + tz = ZoneInfo(settings.APP_TIMEZONE) + return datetime.now(tz).strftime("%Y-%m-%d") diff --git a/app/routes/user.py b/app/routes/user.py new file mode 100644 index 0000000..257dd8f --- /dev/null +++ b/app/routes/user.py @@ -0,0 +1,103 @@ +"""用户数据 JSON API — 收藏、阅读状态、笔记。""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.database import get_db +from app.services.user_data import ( + get_note, + save_note, + set_reading_status, + toggle_bookmark, +) + +router = APIRouter(prefix="/api", tags=["user-data"]) + + +# ── 请求模型 ────────────────────────────────────────────────────────── + + +class ReadingStatusRequest(BaseModel): + status: str + + +class NoteRequest(BaseModel): + content: str + + +# ── 收藏 ────────────────────────────────────────────────────────────── + + +@router.post("/bookmark/{arxiv_id}") +def bookmark_toggle(arxiv_id: str, request: Request, db: Session = Depends(get_db)): + """切换收藏状态。支持 HTMX 局部刷新和 JSON 响应。""" + result = toggle_bookmark(db, arxiv_id) + + if "error" in result: + raise HTTPException(status_code=404, detail=result["error"]) + + # HTMX 请求 → 返回 HTML 片段 + if request.headers.get("HX-Request"): + star = "★" if result["bookmarked"] else "☆" + active_class = " active" if result["bookmarked"] else "" + html = ( + f'" + ) + return HTMLResponse(content=html) + + return result + + +# ── 阅读状态 ────────────────────────────────────────────────────────── + + +@router.post("/reading-status/{arxiv_id}") +def reading_status_update( + arxiv_id: str, + body: ReadingStatusRequest, + db: Session = Depends(get_db), +): + """更新阅读状态。""" + result = set_reading_status(db, arxiv_id, body.status) + + if "error" in result: + if result["error"] == "not_found": + raise HTTPException(status_code=404, detail="Paper not found") + elif result["error"] == "invalid_status": + raise HTTPException( + status_code=422, + detail=f"Invalid status. Valid: {result['valid']}", + ) + + return result + + +# ── 笔记 ────────────────────────────────────────────────────────────── + + +@router.get("/note/{arxiv_id}") +def note_get(arxiv_id: str, db: Session = Depends(get_db)): + """获取笔记。""" + result = get_note(db, arxiv_id) + if result is None: + raise HTTPException(status_code=404, detail="Paper not found") + return result + + +@router.post("/note/{arxiv_id}") +def note_save(arxiv_id: str, body: NoteRequest, db: Session = Depends(get_db)): + """保存笔记。""" + result = save_note(db, arxiv_id, body.content) + + if "error" in result: + raise HTTPException(status_code=404, detail=result["error"]) + + return result diff --git a/app/services/searcher.py b/app/services/searcher.py new file mode 100644 index 0000000..35a0284 --- /dev/null +++ b/app/services/searcher.py @@ -0,0 +1,230 @@ +"""FTS5 全文搜索服务 — 关键词 + 标签筛选,命中片段高亮,分页。""" + +from __future__ import annotations + +import math +import re + +from sqlalchemy import text +from sqlalchemy.orm import Session, joinedload + +from app.models import Paper + +# ── 输入清洗 ────────────────────────────────────────────────────────── + +# FTS5 查询语法中的特殊字符,用户输入时需要移除 +_FTS5_SPECIAL = re.compile(r'["{}()^+:]') + + +def _sanitize_query(raw: str) -> str: + """清洗用户输入,生成安全的 FTS5 MATCH 表达式。 + + - 移除 FTS5 特殊字符 + - 按空白拆分为 token,用 AND 连接 + - 空字符串返回 None + """ + cleaned = _FTS5_SPECIAL.sub("", raw.strip()) + tokens = cleaned.split() + if not tokens: + return None + return " AND ".join(tokens) + + +# ── 核心搜索 ────────────────────────────────────────────────────────── + + +def search_papers( + db: Session, + *, + query: str | None = None, + tag: str | None = None, + sort: str = "relevance", + page: int = 1, + page_size: int = 20, +) -> dict: + """FTS5 搜索论文。 + + 返回:: + { + "results": list[Paper], + "snippets": dict[int, dict], # paper_id → {title_zh, abstract} + "total": int, + "page": int, + "total_pages": int, + } + """ + match_expr = _sanitize_query(query) if query else None + + # ── 无关键词 + 无标签 → 空结果 ── + if not match_expr and not tag: + return { + "results": [], + "snippets": {}, + "total": 0, + "page": page, + "total_pages": 0, + } + + # ── 构建条件性 JOIN 和 WHERE 片段 ── + tag_join = "" + tag_where = "" + tag_params: dict = {} + if tag: + tag_join = "JOIN paper_tags pt ON pt.paper_id = p.id" + tag_where = "AND pt.tag = :tag" + tag_params["tag"] = tag + + offset = (page - 1) * page_size + + if match_expr: + return _search_with_fts( + db, match_expr, tag_join, tag_where, tag_params, + sort, page, page_size, offset, + ) + else: + return _search_tag_only( + db, tag, sort, page, page_size, offset, + ) + + +def _search_with_fts( + db: Session, + match_expr: str, + tag_join: str, + tag_where: str, + tag_params: dict, + sort: str, + page: int, + page_size: int, + offset: int, +) -> dict: + """有关键词时的 FTS5 MATCH 搜索。""" + params = {"query": match_expr, "limit": page_size, "offset": offset} + params.update(tag_params) + + order = "bm25(papers_fts)" if sort == "relevance" else "p.paper_date DESC, p.upvotes DESC" + + # ── 主查询:取 ID + rank + snippet ── + rows_sql = text(f""" + SELECT + p.id, + papers_fts.rank, + snippet(papers_fts, 1, '', '', '...', 32) AS snippet_title_zh, + snippet(papers_fts, 2, '', '', '...', 32) AS snippet_abstract + FROM papers_fts + JOIN papers p ON p.id = papers_fts.rowid + {tag_join} + WHERE papers_fts MATCH :query + {tag_where} + ORDER BY {order} + LIMIT :limit OFFSET :offset + """) + fts_rows = db.execute(rows_sql, params).fetchall() + + # ── 计数查询 ── + count_sql = text(f""" + SELECT COUNT(DISTINCT papers_fts.rowid) + FROM papers_fts + JOIN papers p ON p.id = papers_fts.rowid + {tag_join} + WHERE papers_fts MATCH :query + {tag_where} + """) + total = db.execute(count_sql, params).scalar() or 0 + + paper_ids = [row[0] for row in fts_rows] + snippets = { + row[0]: {"title_zh": row[2], "abstract": row[3]} + for row in fts_rows + } + + papers = _load_papers_by_ids(db, paper_ids, sort, {row[0]: row[1] for row in fts_rows}) + + return { + "results": papers, + "snippets": snippets, + "total": total, + "page": page, + "total_pages": math.ceil(total / page_size) if total else 0, + } + + +def _search_tag_only( + db: Session, + tag: str, + sort: str, + page: int, + page_size: int, + offset: int, +) -> dict: + """只有标签筛选,无关键词。""" + order = "p.paper_date DESC, p.upvotes DESC" if sort == "date" else "p.paper_date DESC, p.upvotes DESC" + + rows_sql = text(f""" + SELECT p.id + FROM papers p + JOIN paper_tags pt ON pt.paper_id = p.id + WHERE pt.tag = :tag + ORDER BY {order} + LIMIT :limit OFFSET :offset + """) + rows = db.execute(rows_sql, {"tag": tag, "limit": page_size, "offset": offset}).fetchall() + + count_sql = text(""" + SELECT COUNT(DISTINCT p.id) + FROM papers p + JOIN paper_tags pt ON pt.paper_id = p.id + WHERE pt.tag = :tag + """) + total = db.execute(count_sql, {"tag": tag}).scalar() or 0 + + paper_ids = [row[0] for row in rows] + papers = _load_papers_by_ids(db, paper_ids) + + return { + "results": papers, + "snippets": {}, + "total": total, + "page": page, + "total_pages": math.ceil(total / page_size) if total else 0, + } + + +def _load_papers_by_ids( + db: Session, + paper_ids: list[int], + sort: str | None = None, + rank_map: dict[int, float] | None = None, +) -> list[Paper]: + """根据 ID 列表加载完整 ORM 对象,保持原始排序。""" + if not paper_ids: + return [] + + papers = ( + db.query(Paper) + .filter(Paper.id.in_(paper_ids)) + .options( + joinedload(Paper.authors), + joinedload(Paper.tags), + joinedload(Paper.summary_status), + joinedload(Paper.bookmark), + joinedload(Paper.reading_status), + ) + .all() + ) + + # 按 FTS rank / tag-only 原始顺序排列 + id_order = {pid: idx for idx, pid in enumerate(paper_ids)} + papers.sort(key=lambda p: id_order.get(p.id, 0)) + return papers + + +# ── 辅助查询 ────────────────────────────────────────────────────────── + + +def get_all_tags(db: Session) -> list[str]: + """返回所有不重复的标签,按字母排序。""" + rows = db.execute( + text("SELECT DISTINCT tag FROM paper_tags ORDER BY tag") + ).fetchall() + return [row[0] for row in rows] diff --git a/app/services/user_data.py b/app/services/user_data.py new file mode 100644 index 0000000..77cb9c9 --- /dev/null +++ b/app/services/user_data.py @@ -0,0 +1,115 @@ +"""用户数据服务 — 收藏、阅读状态、个人笔记。无账号体系,数据写入本地 SQLite。""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from app.models import Paper, UserBookmark, UserNote, UserReadingStatus + +# ── 收藏 ────────────────────────────────────────────────────────────── + + +def toggle_bookmark(db: Session, arxiv_id: str) -> dict: + """切换收藏状态。返回 {"bookmarked": bool, "arxiv_id": str}。""" + paper = db.query(Paper).filter(Paper.arxiv_id == arxiv_id).first() + if not paper: + return {"error": "not_found"} + + existing = db.query(UserBookmark).filter(UserBookmark.paper_id == paper.id).first() + if existing: + db.delete(existing) + db.commit() + return {"bookmarked": False, "arxiv_id": arxiv_id} + else: + bookmark = UserBookmark( + paper_id=paper.id, + created_at=datetime.now(timezone.utc), + ) + db.add(bookmark) + db.commit() + return {"bookmarked": True, "arxiv_id": arxiv_id} + + +# ── 阅读状态 ────────────────────────────────────────────────────────── + +VALID_STATUSES = {"unread", "skimmed", "read_summary", "read_full"} + + +def set_reading_status(db: Session, arxiv_id: str, status: str) -> dict: + """设置阅读状态。status 必须是 unread/skimmed/read_summary/read_full。""" + if status not in VALID_STATUSES: + return {"error": "invalid_status", "valid": sorted(VALID_STATUSES)} + + paper = db.query(Paper).filter(Paper.arxiv_id == arxiv_id).first() + if not paper: + return {"error": "not_found"} + + now = datetime.now(timezone.utc) + existing = ( + db.query(UserReadingStatus) + .filter(UserReadingStatus.paper_id == paper.id) + .first() + ) + if existing: + existing.status = status + existing.updated_at = now + else: + db.add( + UserReadingStatus( + paper_id=paper.id, + status=status, + updated_at=now, + ) + ) + db.commit() + return {"arxiv_id": arxiv_id, "status": status} + + +# ── 笔记 ────────────────────────────────────────────────────────────── + + +def get_note(db: Session, arxiv_id: str) -> dict | None: + """获取笔记。返回 {"arxiv_id", "content", "updated_at"} 或 None(论文不存在时)。""" + paper = db.query(Paper).filter(Paper.arxiv_id == arxiv_id).first() + if not paper: + return None + + note = db.query(UserNote).filter(UserNote.paper_id == paper.id).first() + if not note: + return {"arxiv_id": arxiv_id, "content": "", "updated_at": None} + + return { + "arxiv_id": arxiv_id, + "content": note.content, + "updated_at": note.updated_at.isoformat() if note.updated_at else None, + } + + +def save_note(db: Session, arxiv_id: str, content: str) -> dict: + """创建或更新笔记。返回 {"arxiv_id", "content", "updated_at"}。""" + paper = db.query(Paper).filter(Paper.arxiv_id == arxiv_id).first() + if not paper: + return {"error": "not_found"} + + now = datetime.now(timezone.utc) + existing = db.query(UserNote).filter(UserNote.paper_id == paper.id).first() + if existing: + existing.content = content + existing.updated_at = now + else: + db.add( + UserNote( + paper_id=paper.id, + content=content, + created_at=now, + updated_at=now, + ) + ) + db.commit() + return { + "arxiv_id": arxiv_id, + "content": content, + "updated_at": now.isoformat(), + } diff --git a/app/static/css/style.css b/app/static/css/style.css index 478bb13..ff4ed02 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -326,13 +326,233 @@ a:hover { color: var(--accent-hover); text-decoration: underline; } border-top: 1px solid var(--border); } +/* ── Responsive ─────────────────────────────────────────────────── */ +/* ── Nav Search ──────────────────────────────────────────────────── */ +.nav-search { + display: flex; + align-items: center; + margin-right: auto; +} + +.nav-search-input { + padding: 5px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.85rem; + background: var(--bg); + color: var(--ink); + width: 180px; + transition: border-color 0.2s; + font-family: var(--font-sans); +} +.nav-search-input:focus { + outline: none; + border-color: var(--accent); +} + +/* ── Search Page ────────────────────────────────────────────────── */ +.search-form { + display: flex; + gap: 8px; + margin-bottom: 20px; +} + +.search-input { + flex: 1; + padding: 10px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 1rem; + font-family: var(--font-sans); + background: var(--surface); + color: var(--ink); +} +.search-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(45, 95, 138, 0.1); +} + +.search-btn { + padding: 10px 24px; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} +.search-btn:hover { background: var(--accent-hover); } + +/* ── Tag Filter ─────────────────────────────────────────────────── */ +.tag-filter { + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.tag-filter-label { + font-size: 0.85rem; + color: var(--ink-light); +} +.tag-chip { + display: inline-block; + padding: 4px 10px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 0.8rem; + color: var(--ink-light); +} +.tag-chip:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; } +.tag-chip.active { background: var(--accent); color: #fff; border-color: var(--accent); } + +/* ── Search Meta & Sort ─────────────────────────────────────────── */ +.search-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + font-size: 0.9rem; + color: var(--ink-light); +} +.sort-toggle a { + color: var(--ink-light); + font-size: 0.85rem; +} +.sort-toggle a.active { color: var(--accent); font-weight: 600; } +.sort-toggle a:hover { color: var(--accent); text-decoration: none; } +.sort-divider { color: var(--border); margin: 0 4px; } + +/* ── Search Highlight ───────────────────────────────────────────── */ +mark { + background: #fff3cd; + color: var(--ink); + padding: 1px 2px; + border-radius: 2px; +} + +.paper-snippet { + margin-top: 8px; + color: var(--ink-light); + font-size: 0.92rem; + line-height: 1.6; +} +.paper-date { + margin-left: 12px; + font-size: 0.82rem; + color: var(--ink-light); +} + +/* ── Pagination ─────────────────────────────────────────────────── */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 16px; + margin-top: 32px; + padding-top: 16px; +} +.page-btn { + padding: 6px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.85rem; + color: var(--ink-light); +} +.page-btn:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; } +.page-info { + font-size: 0.85rem; + color: var(--ink-light); +} + +/* ── Reading List ───────────────────────────────────────────────── */ +.page-heading { + font-family: var(--font-body); + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 20px; +} + +.reading-list-filters { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.filter-chip { + display: inline-block; + padding: 6px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.85rem; + color: var(--ink-light); +} +.filter-chip:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; } +.filter-chip.active { background: var(--accent); color: #fff; border-color: var(--accent); } + +/* ── Paper Card Footer (enhanced) ──────────────────────────────── */ +.paper-footer { + margin-top: 12px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.paper-footer-left { + display: flex; + align-items: center; + gap: 8px; +} + +.paper-footer-right { + display: flex; + align-items: center; + gap: 10px; +} + +/* ── Bookmark Button ────────────────────────────────────────────── */ +.btn-bookmark { + background: none; + border: none; + font-size: 1.2rem; + cursor: pointer; + color: var(--ink-light); + padding: 2px 4px; + transition: color 0.2s; + line-height: 1; +} +.btn-bookmark:hover { color: var(--accent); } +.btn-bookmark.active { color: #f0a500; } + +/* ── Reading Badge ──────────────────────────────────────────────── */ +.reading-badge { + font-size: 0.75rem; + padding: 2px 6px; + border-radius: 3px; +} +.reading-unread { background: #f0f0f0; color: #888; } +.reading-skimmed { background: #e3f2fd; color: #1976d2; } +.reading-read_summary { background: #e8f5e9; color: #388e3c; } +.reading-read_full { background: #e8f5e9; color: #2e7d32; font-weight: 500; } + /* ── Responsive ─────────────────────────────────────────────────── */ @media (max-width: 640px) { .container { padding: 16px; } .nav-bar { padding: 10px 16px; } + .nav-search-input { width: 120px; } .date-nav { gap: 8px; } .date-title { font-size: 1.2rem; } .paper-card { padding: 14px 16px; } .detail-title { font-size: 1.3rem; } .detail-meta { flex-direction: column; gap: 4px; } + .search-form { flex-direction: column; } + .reading-list-filters { gap: 4px; } + .filter-chip { padding: 4px 10px; font-size: 0.8rem; } } diff --git a/app/static/js/app.js b/app/static/js/app.js index 8e8813e..5eb7142 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1 +1,18 @@ -/* app.js — 基础前端交互(HTMX 后续增强) */ +/* app.js — 基础前端交互 */ + +// Ctrl+K 或 / 聚焦搜索框 +document.addEventListener("keydown", function (e) { + var input = document.querySelector(".nav-search-input"); + if (!input) return; + + // 忽略在输入框内的按键 + if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return; + + if ((e.ctrlKey || e.metaKey) && e.key === "k") { + e.preventDefault(); + input.focus(); + } else if (e.key === "/") { + e.preventDefault(); + input.focus(); + } +}); diff --git a/app/templates/base.html b/app/templates/base.html index a90aea7..fe2ccb3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -10,8 +10,11 @@