forked from retoor/devplacepy
docs: document shared search pattern for feed, gists, and project listings
Add a reusable `text_search_clause` helper in `database.py` that builds a SQLAlchemy `or_` of `ilike` clauses over specified columns, returning `None` when search is blank or the table lacks the columns. Wire it into `get_feed_posts`, `get_gists_list`, and the projects listing so all three public index pages support free-text search via a `search` query parameter. Introduce `_sidebar_search.html` as the single search-box partial, included at the top of each listing's left filter panel with `_action`, `_placeholder`, and `_hidden` locals to preserve active category/tab filters on submit. Expose the `search` field on `FeedOut`, `GistsOut`, and `ProjectsOut` API schemas with corresponding OpenAPI documentation. Update `CLAUDE.md` to note the rate-limiter exemption for `GET`/`HEAD` and the `/openai` gateway, and refresh `README.md` route tables to mention the new search capability on `/feed`, `/gists`, and `/projects`.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import dataset
|
||||
import logging
|
||||
from sqlalchemy import or_
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from devplacepy.cache import TTLCache
|
||||
@@ -2035,6 +2036,15 @@ def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
|
||||
PAGE_SIZE = 25
|
||||
|
||||
|
||||
def text_search_clause(table, search, fields=("title", "description")):
|
||||
if not search or not search.strip() or not table.exists:
|
||||
return None
|
||||
columns = table.table.columns
|
||||
like = f"%{search.strip()}%"
|
||||
matches = [columns[field].ilike(like) for field in fields if field in columns]
|
||||
return or_(*matches) if matches else None
|
||||
|
||||
|
||||
def paginate(
|
||||
table, *clauses, before=None, order=None, cursor_field="created_at", **filters
|
||||
):
|
||||
|
||||
@@ -719,6 +719,14 @@ four ways to sign requests.
|
||||
field(
|
||||
"topic", "query", "enum", False, "", "Filter by topic.", TOPICS
|
||||
),
|
||||
field(
|
||||
"search",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Search post title and content.",
|
||||
),
|
||||
field("before", "query", "string", False, "", "Pagination cursor."),
|
||||
],
|
||||
),
|
||||
@@ -1382,6 +1390,14 @@ four ways to sign requests.
|
||||
"",
|
||||
"Filter by author UID.",
|
||||
),
|
||||
field(
|
||||
"search",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Search gist title and description.",
|
||||
),
|
||||
field("before", "query", "string", False, "", "Pagination cursor."),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -15,6 +15,7 @@ from devplacepy.database import (
|
||||
get_user_bookmarks,
|
||||
get_polls_by_post_uids,
|
||||
paginate_diverse,
|
||||
text_search_clause,
|
||||
)
|
||||
from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.content import enrich_items
|
||||
@@ -27,9 +28,13 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None):
|
||||
def get_feed_posts(
|
||||
user, tab: str = "all", topic: str = None, search: str = "", before: str = None
|
||||
):
|
||||
posts_table = get_table("posts")
|
||||
order = ["-stars", "-created_at"] if tab == "trending" else ["-created_at"]
|
||||
search_match = text_search_clause(posts_table, search, ("title", "content"))
|
||||
search_clauses = [search_match] if search_match is not None else []
|
||||
|
||||
if tab == "following":
|
||||
if not user:
|
||||
@@ -44,6 +49,7 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
|
||||
posts, next_cursor = paginate_diverse(
|
||||
posts_table,
|
||||
posts_table.table.columns.user_uid.in_(following),
|
||||
*search_clauses,
|
||||
before=before,
|
||||
order=order,
|
||||
max_per_author=2,
|
||||
@@ -51,7 +57,12 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
|
||||
else:
|
||||
filters = {"topic": topic} if topic else {}
|
||||
posts, next_cursor = paginate_diverse(
|
||||
posts_table, before=before, order=order, max_per_author=2, **filters
|
||||
posts_table,
|
||||
*search_clauses,
|
||||
before=before,
|
||||
order=order,
|
||||
max_per_author=2,
|
||||
**filters,
|
||||
)
|
||||
|
||||
if not posts:
|
||||
@@ -66,10 +77,14 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def feed_page(
|
||||
request: Request, tab: str = "all", topic: str = None, before: str = None
|
||||
request: Request,
|
||||
tab: str = "all",
|
||||
topic: str = None,
|
||||
search: str = "",
|
||||
before: str = None,
|
||||
):
|
||||
user = get_current_user(request)
|
||||
posts, next_cursor = get_feed_posts(user, tab, topic, before)
|
||||
posts, next_cursor = get_feed_posts(user, tab, topic, search, before)
|
||||
stats = get_site_stats()
|
||||
top_authors = get_top_authors(5)
|
||||
daily_topic = get_daily_topic()
|
||||
@@ -110,6 +125,7 @@ async def feed_page(
|
||||
"posts": posts,
|
||||
"current_tab": tab,
|
||||
"current_topic": topic,
|
||||
"search": search,
|
||||
"total_members": stats["total_members"],
|
||||
"posts_today": stats["posts_today"],
|
||||
"total_projects": stats["total_projects"],
|
||||
|
||||
@@ -10,6 +10,7 @@ from devplacepy.database import (
|
||||
get_users_by_uids,
|
||||
get_gist_languages,
|
||||
paginate,
|
||||
text_search_clause,
|
||||
)
|
||||
from devplacepy.content import (
|
||||
load_detail,
|
||||
@@ -67,7 +68,7 @@ LANGUAGES = [
|
||||
]
|
||||
|
||||
|
||||
def get_gists_list(user_uid=None, language=None, before=None, viewer=None):
|
||||
def get_gists_list(user_uid=None, language=None, search="", before=None, viewer=None):
|
||||
gists_table = get_table("gists")
|
||||
filters = {}
|
||||
if user_uid:
|
||||
@@ -75,8 +76,11 @@ def get_gists_list(user_uid=None, language=None, before=None, viewer=None):
|
||||
if language:
|
||||
filters["language"] = language
|
||||
|
||||
total = gists_table.count(deleted_at=None, **filters)
|
||||
gists, next_cursor = paginate(gists_table, before=before, **filters)
|
||||
search_match = text_search_clause(gists_table, search, ("title", "description"))
|
||||
clauses = [search_match] if search_match is not None else []
|
||||
|
||||
total = gists_table.count(*clauses, deleted_at=None, **filters)
|
||||
gists, next_cursor = paginate(gists_table, *clauses, before=before, **filters)
|
||||
|
||||
if not gists:
|
||||
return [], next_cursor, total
|
||||
@@ -87,11 +91,15 @@ def get_gists_list(user_uid=None, language=None, before=None, viewer=None):
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def gists_page(
|
||||
request: Request, language: str = None, user_uid: str = None, before: str = None
|
||||
request: Request,
|
||||
language: str = None,
|
||||
user_uid: str = None,
|
||||
search: str = "",
|
||||
before: str = None,
|
||||
):
|
||||
user = get_current_user(request)
|
||||
gists_list, next_cursor, total_count = get_gists_list(
|
||||
user_uid, language, before, viewer=user
|
||||
user_uid, language, search, before, viewer=user
|
||||
)
|
||||
seo_ctx = list_page_seo(
|
||||
request,
|
||||
@@ -114,6 +122,7 @@ async def gists_page(
|
||||
"total_count": total_count,
|
||||
"next_cursor": next_cursor,
|
||||
"current_language": language,
|
||||
"search": search,
|
||||
"languages": LANGUAGES,
|
||||
"gist_language_codes": get_gist_languages(),
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ from devplacepy.database import (
|
||||
get_site_stats,
|
||||
get_user_votes,
|
||||
paginate,
|
||||
text_search_clause,
|
||||
resolve_by_slug,
|
||||
get_fork_parent,
|
||||
count_forks,
|
||||
@@ -74,11 +75,9 @@ def get_projects_list(
|
||||
if projects.exists:
|
||||
columns = projects.table.columns
|
||||
clauses.append(columns.deleted_at.is_(None))
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
clauses.append(
|
||||
or_(columns.title.ilike(like), columns.description.ilike(like))
|
||||
)
|
||||
search_match = text_search_clause(projects, search, ("title", "description"))
|
||||
if search_match is not None:
|
||||
clauses.append(search_match)
|
||||
if not is_admin(viewer) and "is_private" in columns:
|
||||
visible = or_(columns.is_private.is_(None), columns.is_private == 0)
|
||||
if viewer:
|
||||
|
||||
@@ -428,6 +428,7 @@ class FeedOut(_Out):
|
||||
posts: list[FeedItemOut] = []
|
||||
current_tab: Optional[str] = None
|
||||
current_topic: Optional[str] = None
|
||||
search: Optional[str] = None
|
||||
next_cursor: Optional[str] = None
|
||||
total_members: Optional[int] = None
|
||||
posts_today: Optional[int] = None
|
||||
@@ -488,6 +489,7 @@ class GistsOut(_Out):
|
||||
total_count: Optional[int] = None
|
||||
next_cursor: Optional[str] = None
|
||||
current_language: Optional[str] = None
|
||||
search: Optional[str] = None
|
||||
languages: list[tuple[str, str]] = []
|
||||
gist_language_codes: list[str] = []
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ ACTIONS: tuple[Action, ...] = (
|
||||
params=(
|
||||
query("tab", "Feed tab to view."),
|
||||
query("topic", "Filter by topic."),
|
||||
query("search", "Search post title and content."),
|
||||
query("before", "Pagination cursor."),
|
||||
),
|
||||
),
|
||||
@@ -888,6 +889,7 @@ ACTIONS: tuple[Action, ...] = (
|
||||
params=(
|
||||
query("language", "Filter by language."),
|
||||
query("user_uid", "Filter by owner uid."),
|
||||
query("search", "Search gist title and description."),
|
||||
query("before", "Pagination cursor."),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<h1 class="sr-only">Feed</h1>
|
||||
<div class="feed-layout">
|
||||
<aside class="sidebar-card">
|
||||
{% set _action = "/feed" %}{% set _placeholder = "Search posts..." %}{% set _hidden = {"tab": current_tab, "topic": current_topic} %}{% include "_sidebar_search.html" %}
|
||||
|
||||
<div class="sidebar-heading">Topics</div>
|
||||
<div class="sidebar-nav">
|
||||
<a href="/feed" class="sidebar-link {% if not current_topic %}active{% endif %}">
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
{% block content %}
|
||||
<div class="gists-layout">
|
||||
<aside class="sidebar-card">
|
||||
{% set _action = "/gists" %}{% set _placeholder = "Search gists..." %}{% set _hidden = {"language": current_language, "user_uid": request.query_params.get('user_uid')} %}{% include "_sidebar_search.html" %}
|
||||
|
||||
<div class="sidebar-heading">Languages</div>
|
||||
<div class="sidebar-nav">
|
||||
<a href="/gists" class="sidebar-link {% if not current_language %}active{% endif %}">
|
||||
|
||||
@@ -8,12 +8,7 @@
|
||||
{% block content %}
|
||||
<div class="projects-layout">
|
||||
<aside class="sidebar-card">
|
||||
<div class="sidebar-heading">Filter</div>
|
||||
<div class="sidebar-section">
|
||||
<form method="GET" action="/projects">
|
||||
<input type="text" name="search" placeholder="Search projects..." value="{{ search }}">
|
||||
</form>
|
||||
</div>
|
||||
{% set _action = "/projects" %}{% set _placeholder = "Search projects..." %}{% set _hidden = {"tab": current_tab, "project_type": project_type} %}{% include "_sidebar_search.html" %}
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-heading">Type</div>
|
||||
|
||||
Reference in New Issue
Block a user