feat: file search, type/tag filters, and pagination

Add tsvector full-text search, type filter (image/document/text/
spreadsheet/archive), tag filter dropdown, and pagination (50/page).
Replace folder button bar with compact dropdown. All filters combine
and carry through sort and pagination links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 01:05:10 +00:00
parent 41b974c804
commit 75b055299a
2 changed files with 173 additions and 33 deletions

View File

@@ -144,12 +144,27 @@ SORT_OPTIONS = {
"date_asc": "created_at ASC",
}
# Map type filter values to mime prefixes/patterns
TYPE_FILTERS = {
"image": "image/%",
"document": "application/pdf",
"text": "text/%",
"spreadsheet": "application/vnd.%sheet%",
"archive": "application/%zip%",
}
PER_PAGE = 50
@router.get("/")
async def list_files(
request: Request,
folder: Optional[str] = None,
sort: Optional[str] = None,
q: Optional[str] = None,
file_type: Optional[str] = None,
tag: Optional[str] = None,
page: int = 1,
context_type: Optional[str] = None,
context_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
@@ -163,38 +178,86 @@ async def list_files(
order_by = SORT_OPTIONS.get(sort, "storage_path ASC")
# Normalize folder param from form: " " (space) = root, "" = all, None = all
if folder is not None:
if folder.strip() == "" and folder != "":
# Space-only value means root folder
folder = ""
elif folder.strip() == "":
# Empty string means "all folders" (no filter)
folder = None
# Build dynamic WHERE clauses
where_clauses = ["f.is_deleted = false"]
params = {}
if context_type and context_id:
# Files attached to a specific entity
result = await db.execute(text(f"""
where_clauses.append("fm.context_type = :ct AND fm.context_id = :cid")
params["ct"] = context_type
params["cid"] = context_id
if folder is not None:
if folder == "":
where_clauses.append("f.storage_path NOT LIKE '%/%'")
else:
where_clauses.append("f.storage_path LIKE :prefix")
params["prefix"] = folder + "/%"
if q and q.strip():
search_terms = q.strip().split()
tsquery = " & ".join(f"{t}:*" for t in search_terms)
where_clauses.append("f.search_vector @@ to_tsquery('english', :tsquery)")
params["tsquery"] = tsquery
if file_type and file_type in TYPE_FILTERS:
where_clauses.append("f.mime_type LIKE :mime_pattern")
params["mime_pattern"] = TYPE_FILTERS[file_type]
if tag and tag.strip():
where_clauses.append(":tag = ANY(f.tags)")
params["tag"] = tag.strip()
where_sql = " AND ".join(where_clauses)
# Count total for pagination
if context_type and context_id:
count_sql = f"""
SELECT COUNT(*) FROM files f
JOIN file_mappings fm ON fm.file_id = f.id
WHERE {where_sql}
"""
else:
count_sql = f"SELECT COUNT(*) FROM files f WHERE {where_sql}"
total = (await db.execute(text(count_sql), params)).scalar()
# Paginate
if page < 1:
page = 1
offset = (page - 1) * PER_PAGE
total_pages = max(1, (total + PER_PAGE - 1) // PER_PAGE)
# Query
if context_type and context_id:
query_sql = f"""
SELECT f.*, fm.context_type, fm.context_id
FROM files f
JOIN file_mappings fm ON fm.file_id = f.id
WHERE f.is_deleted = false AND fm.context_type = :ct AND fm.context_id = :cid
WHERE {where_sql}
ORDER BY {order_by}
"""), {"ct": context_type, "cid": context_id})
elif folder is not None:
if folder == "":
# Root folder: files with no directory separator in storage_path
result = await db.execute(text(f"""
SELECT * FROM files
WHERE is_deleted = false AND storage_path NOT LIKE '%/%'
ORDER BY {order_by}
"""))
LIMIT :lim OFFSET :off
"""
else:
# Specific folder: storage_path starts with folder/
result = await db.execute(text(f"""
SELECT * FROM files
WHERE is_deleted = false AND storage_path LIKE :prefix
query_sql = f"""
SELECT f.* FROM files f
WHERE {where_sql}
ORDER BY {order_by}
"""), {"prefix": folder + "/%"})
else:
# All files
result = await db.execute(text(f"""
SELECT * FROM files
WHERE is_deleted = false
ORDER BY {order_by}
"""))
LIMIT :lim OFFSET :off
"""
params["lim"] = PER_PAGE
params["off"] = offset
result = await db.execute(text(query_sql), params)
items = [dict(r._mapping) for r in result]
# Add derived folder field for display
@@ -202,10 +265,23 @@ async def list_files(
dirname = os.path.dirname(item["storage_path"])
item["folder"] = dirname if dirname else "/"
# Get all unique tags for the tag filter dropdown
tag_result = await db.execute(text(
"SELECT DISTINCT unnest(tags) AS tag FROM files WHERE is_deleted = false AND tags IS NOT NULL ORDER BY tag"
))
all_tags = [r._mapping["tag"] for r in tag_result]
return templates.TemplateResponse("files.html", {
"request": request, "sidebar": sidebar, "items": items,
"folders": folders, "current_folder": folder,
"current_sort": sort or "path",
"current_q": q or "",
"current_type": file_type or "",
"current_tag": tag or "",
"current_page": page,
"total_pages": total_pages,
"total_files": total,
"all_tags": all_tags,
"sync_result": sync_result,
"context_type": context_type or "",
"context_id": context_id or "",

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Files<span class="page-count">{{ items|length }}</span></h1>
<h1 class="page-title">Files<span class="page-count">{{ total_files }}</span></h1>
<div class="flex gap-2">
<form action="/files/sync" method="post" style="display:inline">
<button type="submit" class="btn btn-secondary">Sync Files</button>
@@ -16,18 +16,51 @@
</div>
{% endif %}
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px;">
<label style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">Folder:</label>
<select class="form-input" style="max-width: 280px; padding: 6px 10px; font-size: 0.85rem;" onchange="window.location.href=this.value">
<option value="/files" {{ 'selected' if current_folder is none }}>All folders</option>
<option value="/files?folder=" {{ 'selected' if current_folder is not none and current_folder == '' }}>/ (root)</option>
<form class="filters-bar" method="get" action="/files" style="display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 16px;">
<input type="text" name="q" value="{{ current_q }}" class="form-input" placeholder="Search files..." style="max-width: 220px; padding: 6px 10px; font-size: 0.85rem;">
<select name="folder" class="form-input" style="max-width: 200px; padding: 6px 10px; font-size: 0.85rem;" onchange="this.form.submit()">
<option value="" {{ 'selected' if current_folder is none }}>All folders</option>
<option value=" " {{ 'selected' if current_folder is not none and current_folder == '' }}>/ (root)</option>
{% for f in folders %}
<option value="/files?folder={{ f }}" {{ 'selected' if current_folder == f }}>{% if '/' in f %}&nbsp;&nbsp;{{ f.split('/')[-1] }}{% else %}{{ f }}{% endif %}</option>
<option value="{{ f }}" {{ 'selected' if current_folder == f }}>{% if '/' in f %}&nbsp;&nbsp;{{ f.split('/')[-1] }}{% else %}{{ f }}{% endif %}</option>
{% endfor %}
</select>
</div>
{% set sort_base = '/files?' ~ ('folder=' ~ current_folder ~ '&' if current_folder is not none else '') %}
<select name="file_type" class="form-input" style="max-width: 160px; padding: 6px 10px; font-size: 0.85rem;" onchange="this.form.submit()">
<option value="">All types</option>
<option value="image" {{ 'selected' if current_type == 'image' }}>Images</option>
<option value="document" {{ 'selected' if current_type == 'document' }}>Documents</option>
<option value="text" {{ 'selected' if current_type == 'text' }}>Text</option>
<option value="spreadsheet" {{ 'selected' if current_type == 'spreadsheet' }}>Spreadsheets</option>
<option value="archive" {{ 'selected' if current_type == 'archive' }}>Archives</option>
</select>
{% if all_tags %}
<select name="tag" class="form-input" style="max-width: 160px; padding: 6px 10px; font-size: 0.85rem;" onchange="this.form.submit()">
<option value="">All tags</option>
{% for t in all_tags %}
<option value="{{ t }}" {{ 'selected' if current_tag == t }}>{{ t }}</option>
{% endfor %}
</select>
{% endif %}
{% if current_q or current_type or current_tag or current_folder is not none %}
<input type="hidden" name="sort" value="{{ current_sort }}">
<button type="submit" class="btn btn-primary btn-xs">Search</button>
<a href="/files" class="btn btn-ghost btn-xs">Clear</a>
{% else %}
<button type="submit" class="btn btn-primary btn-xs">Search</button>
{% endif %}
</form>
{% set qp = [] %}
{% if current_folder is not none %}{% if current_folder == '' %}{{ qp.append('folder= ') or '' }}{% else %}{{ qp.append('folder=' ~ current_folder) or '' }}{% endif %}{% endif %}
{% if current_q %}{{ qp.append('q=' ~ current_q) or '' }}{% endif %}
{% if current_type %}{{ qp.append('file_type=' ~ current_type) or '' }}{% endif %}
{% if current_tag %}{{ qp.append('tag=' ~ current_tag) or '' }}{% endif %}
{% set filter_qs = qp | join('&') %}
{% set sort_base = '/files?' ~ (filter_qs ~ '&' if filter_qs else '') %}
{% if items %}
<div class="card" style="overflow-x: auto;">
@@ -81,11 +114,42 @@
</tbody>
</table>
</div>
{% if total_pages > 1 %}
{% set page_base = sort_base ~ 'sort=' ~ current_sort ~ '&' %}
<div style="display: flex; justify-content: center; align-items: center; gap: 8px; margin-top: 16px;">
{% if current_page > 1 %}
<a href="{{ page_base }}page={{ current_page - 1 }}" class="btn btn-ghost btn-xs">Prev</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == current_page %}
<span class="btn btn-primary btn-xs" style="pointer-events: none;">{{ p }}</span>
{% elif p <= 3 or p >= total_pages - 2 or (p >= current_page - 1 and p <= current_page + 1) %}
<a href="{{ page_base }}page={{ p }}" class="btn btn-ghost btn-xs">{{ p }}</a>
{% elif p == 4 and current_page > 5 %}
<span style="color: var(--muted);">...</span>
{% elif p == total_pages - 3 and current_page < total_pages - 4 %}
<span style="color: var(--muted);">...</span>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="{{ page_base }}page={{ current_page + 1 }}" class="btn btn-ghost btn-xs">Next</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="empty-state">
<div class="empty-state-icon">&#128193;</div>
{% if current_q or current_type or current_tag %}
<div class="empty-state-text">No files match your filters</div>
<a href="/files" class="btn btn-secondary">Clear Filters</a>
{% else %}
<div class="empty-state-text">No files{{ ' in this folder' if current_folder is not none else ' uploaded yet' }}</div>
<a href="/files/upload" class="btn btn-primary">Upload First File</a>
{% endif %}
</div>
{% endif %}
{% endblock %}