feat: sortable file table and dropdown folder picker

Replace flat folder button bar with compact dropdown select.
Add sortable columns (path, name, date) to file list table.
Restore soft-deleted files on sync when file still on disk.
Serve text file previews with white background.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 00:49:16 +00:00
parent ff9be1249a
commit 41b974c804
2 changed files with 92 additions and 41 deletions

View File

@@ -91,9 +91,11 @@ async def sync_files(db: AsyncSession):
)) ))
db_records = [dict(r._mapping) for r in result] db_records = [dict(r._mapping) for r in result]
# Build lookup sets # Build lookup maps
all_db_paths = {r["storage_path"] for r in db_records} all_db_paths = {r["storage_path"] for r in db_records}
active_db_paths = {r["storage_path"] for r in db_records if not r["is_deleted"]} active_db_paths = {r["storage_path"] for r in db_records if not r["is_deleted"]}
deleted_on_disk = {r["storage_path"]: r for r in db_records
if r["is_deleted"] and r["storage_path"] in disk_files}
# New on disk, not in DB at all → create record # New on disk, not in DB at all → create record
new_files = disk_files - all_db_paths new_files = disk_files - all_db_paths
@@ -116,6 +118,12 @@ async def sync_files(db: AsyncSession):
}) })
added += 1 added += 1
# Soft-deleted in DB but still on disk → restore
for rel_path, record in deleted_on_disk.items():
repo = BaseRepository("files", db)
await repo.restore(record["id"])
added += 1
# Active in DB but missing from disk → soft-delete # Active in DB but missing from disk → soft-delete
missing_files = active_db_paths - disk_files missing_files = active_db_paths - disk_files
for record in db_records: for record in db_records:
@@ -127,10 +135,21 @@ async def sync_files(db: AsyncSession):
return {"added": added, "removed": removed} return {"added": added, "removed": removed}
SORT_OPTIONS = {
"path": "storage_path ASC",
"path_desc": "storage_path DESC",
"name": "original_filename ASC",
"name_desc": "original_filename DESC",
"date": "created_at DESC",
"date_asc": "created_at ASC",
}
@router.get("/") @router.get("/")
async def list_files( async def list_files(
request: Request, request: Request,
folder: Optional[str] = None, folder: Optional[str] = None,
sort: Optional[str] = None,
context_type: Optional[str] = None, context_type: Optional[str] = None,
context_id: Optional[str] = None, context_id: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -142,36 +161,38 @@ async def list_files(
folders = get_folders() folders = get_folders()
order_by = SORT_OPTIONS.get(sort, "storage_path ASC")
if context_type and context_id: if context_type and context_id:
# Files attached to a specific entity # Files attached to a specific entity
result = await db.execute(text(""" result = await db.execute(text(f"""
SELECT f.*, fm.context_type, fm.context_id SELECT f.*, fm.context_type, fm.context_id
FROM files f FROM files f
JOIN file_mappings fm ON fm.file_id = f.id 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 f.is_deleted = false AND fm.context_type = :ct AND fm.context_id = :cid
ORDER BY f.created_at DESC ORDER BY {order_by}
"""), {"ct": context_type, "cid": context_id}) """), {"ct": context_type, "cid": context_id})
elif folder is not None: elif folder is not None:
if folder == "": if folder == "":
# Root folder: files with no directory separator in storage_path # Root folder: files with no directory separator in storage_path
result = await db.execute(text(""" result = await db.execute(text(f"""
SELECT * FROM files SELECT * FROM files
WHERE is_deleted = false AND storage_path NOT LIKE '%/%' WHERE is_deleted = false AND storage_path NOT LIKE '%/%'
ORDER BY created_at DESC ORDER BY {order_by}
""")) """))
else: else:
# Specific folder: storage_path starts with folder/ # Specific folder: storage_path starts with folder/
result = await db.execute(text(""" result = await db.execute(text(f"""
SELECT * FROM files SELECT * FROM files
WHERE is_deleted = false AND storage_path LIKE :prefix WHERE is_deleted = false AND storage_path LIKE :prefix
ORDER BY created_at DESC ORDER BY {order_by}
"""), {"prefix": folder + "/%"}) """), {"prefix": folder + "/%"})
else: else:
# All files # All files
result = await db.execute(text(""" result = await db.execute(text(f"""
SELECT * FROM files SELECT * FROM files
WHERE is_deleted = false WHERE is_deleted = false
ORDER BY created_at DESC ORDER BY {order_by}
""")) """))
items = [dict(r._mapping) for r in result] items = [dict(r._mapping) for r in result]
@@ -184,6 +205,7 @@ async def list_files(
return templates.TemplateResponse("files.html", { return templates.TemplateResponse("files.html", {
"request": request, "sidebar": sidebar, "items": items, "request": request, "sidebar": sidebar, "items": items,
"folders": folders, "current_folder": folder, "folders": folders, "current_folder": folder,
"current_sort": sort or "path",
"sync_result": sync_result, "sync_result": sync_result,
"context_type": context_type or "", "context_type": context_type or "",
"context_id": context_id or "", "context_id": context_id or "",

View File

@@ -16,41 +16,70 @@
</div> </div>
{% endif %} {% endif %}
{% if folders %} <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px;">
<div class="filter-bar" style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px;"> <label style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">Folder:</label>
<a href="/files" class="btn btn-xs {{ 'btn-primary' if current_folder is none else 'btn-ghost' }}">All</a> <select class="form-input" style="max-width: 280px; padding: 6px 10px; font-size: 0.85rem;" onchange="window.location.href=this.value">
<a href="/files?folder=" class="btn btn-xs {{ 'btn-primary' if current_folder is not none and current_folder == '' else 'btn-ghost' }}">/</a> <option value="/files" {{ 'selected' if current_folder is none }}>All folders</option>
{% for f in folders %} <option value="/files?folder=" {{ 'selected' if current_folder is not none and current_folder == '' }}>/ (root)</option>
<a href="/files?folder={{ f }}" class="btn btn-xs {{ 'btn-primary' if current_folder == f else 'btn-ghost' }}">{{ f }}</a> {% for f in folders %}
{% endfor %} <option value="/files?folder={{ f }}" {{ 'selected' if current_folder == f }}>{% if '/' in f %}&nbsp;&nbsp;{{ f.split('/')[-1] }}{% else %}{{ f }}{% endif %}</option>
{% endfor %}
</select>
</div> </div>
{% endif %}
{% set sort_base = '/files?' ~ ('folder=' ~ current_folder ~ '&' if current_folder is not none else '') %}
{% if items %} {% if items %}
<div class="card"> <div class="card" style="overflow-x: auto;">
{% for item in items %} <table class="data-table" style="width: 100%; border-collapse: collapse;">
<div class="list-row"> <thead>
<span class="row-title"> <tr style="border-bottom: 1px solid var(--border); text-align: left;">
<a href="/files/{{ item.id }}/preview">{{ item.original_filename }}</a> <th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
</span> <a href="{{ sort_base }}sort={{ 'path_desc' if current_sort == 'path' else 'path' }}" style="color: var(--muted); text-decoration: none;">
<span class="row-meta" style="color: var(--muted); font-size: 0.8rem;">{{ item.folder }}</span> Path {{ '▲' if current_sort == 'path' else ('▼' if current_sort == 'path_desc' else '') }}
{% if item.mime_type %} </a>
<span class="row-tag">{{ item.mime_type.split('/')|last }}</span> </th>
{% endif %} <th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
{% if item.size_bytes %} <a href="{{ sort_base }}sort={{ 'name_desc' if current_sort == 'name' else 'name' }}" style="color: var(--muted); text-decoration: none;">
<span class="row-meta">{{ "%.1f"|format(item.size_bytes / 1024) }} KB</span> Name {{ '▲' if current_sort == 'name' else ('▼' if current_sort == 'name_desc' else '') }}
{% endif %} </a>
{% if item.description %} </th>
<span class="row-meta">{{ item.description[:50] }}</span> <th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">Type</th>
{% endif %} <th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">Size</th>
<div class="row-actions"> <th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
<a href="/files/{{ item.id }}/download" class="btn btn-ghost btn-xs">Download</a> <a href="{{ sort_base }}sort={{ 'date_asc' if current_sort == 'date' else 'date' }}" style="color: var(--muted); text-decoration: none;">
<form action="/files/{{ item.id }}/delete" method="post" data-confirm="Delete this file?" style="display:inline"> Date {{ '▼' if current_sort == 'date' else ('▲' if current_sort == 'date_asc' else '') }}
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button> </a>
</form> </th>
</div> <th style="padding: 10px 12px;"></th>
</div> </tr>
{% endfor %} </thead>
<tbody>
{% for item in items %}
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 10px 12px; color: var(--muted); font-size: 0.85rem;">{{ item.folder }}</td>
<td style="padding: 10px 12px;">
<a href="/files/{{ item.id }}/preview" style="color: var(--accent);">{{ item.original_filename }}</a>
</td>
<td style="padding: 10px 12px;">
{% if item.mime_type %}<span class="row-tag">{{ item.mime_type.split('/')|last }}</span>{% endif %}
</td>
<td style="padding: 10px 12px; color: var(--muted); font-size: 0.85rem; white-space: nowrap;">
{% if item.size_bytes %}{{ "%.1f"|format(item.size_bytes / 1024) }} KB{% endif %}
</td>
<td style="padding: 10px 12px; color: var(--muted); font-size: 0.85rem; white-space: nowrap;">
{{ item.created_at.strftime('%Y-%m-%d') if item.created_at else '' }}
</td>
<td style="padding: 10px 12px; text-align: right; white-space: nowrap;">
<a href="/files/{{ item.id }}/download" class="btn btn-ghost btn-xs">Download</a>
<form action="/files/{{ item.id }}/delete" method="post" data-confirm="Delete this file?" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">