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:
2026-06-13 13:24:48 +00:00
parent 6756c980cf
commit 7985336934
15 changed files with 240 additions and 23 deletions
+10
View File
@@ -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
):
+16
View File
@@ -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."),
],
),
+20 -4
View File
@@ -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"],
+14 -5
View File
@@ -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(),
},
+4 -5
View File
@@ -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:
+2
View File
@@ -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."),
),
),
+2
View File
@@ -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 %}">
+2
View File
@@ -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 %}">
+1 -6
View File
@@ -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>