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:
@@ -91,9 +91,11 @@ async def sync_files(db: AsyncSession):
|
||||
))
|
||||
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}
|
||||
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_files = disk_files - all_db_paths
|
||||
@@ -116,6 +118,12 @@ async def sync_files(db: AsyncSession):
|
||||
})
|
||||
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
|
||||
missing_files = active_db_paths - disk_files
|
||||
for record in db_records:
|
||||
@@ -127,10 +135,21 @@ async def sync_files(db: AsyncSession):
|
||||
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("/")
|
||||
async def list_files(
|
||||
request: Request,
|
||||
folder: Optional[str] = None,
|
||||
sort: Optional[str] = None,
|
||||
context_type: Optional[str] = None,
|
||||
context_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -142,36 +161,38 @@ async def list_files(
|
||||
|
||||
folders = get_folders()
|
||||
|
||||
order_by = SORT_OPTIONS.get(sort, "storage_path ASC")
|
||||
|
||||
if context_type and context_id:
|
||||
# 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
|
||||
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
|
||||
ORDER BY f.created_at DESC
|
||||
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("""
|
||||
result = await db.execute(text(f"""
|
||||
SELECT * FROM files
|
||||
WHERE is_deleted = false AND storage_path NOT LIKE '%/%'
|
||||
ORDER BY created_at DESC
|
||||
ORDER BY {order_by}
|
||||
"""))
|
||||
else:
|
||||
# Specific folder: storage_path starts with folder/
|
||||
result = await db.execute(text("""
|
||||
result = await db.execute(text(f"""
|
||||
SELECT * FROM files
|
||||
WHERE is_deleted = false AND storage_path LIKE :prefix
|
||||
ORDER BY created_at DESC
|
||||
ORDER BY {order_by}
|
||||
"""), {"prefix": folder + "/%"})
|
||||
else:
|
||||
# All files
|
||||
result = await db.execute(text("""
|
||||
result = await db.execute(text(f"""
|
||||
SELECT * FROM files
|
||||
WHERE is_deleted = false
|
||||
ORDER BY created_at DESC
|
||||
ORDER BY {order_by}
|
||||
"""))
|
||||
|
||||
items = [dict(r._mapping) for r in result]
|
||||
@@ -184,6 +205,7 @@ async def list_files(
|
||||
return templates.TemplateResponse("files.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"folders": folders, "current_folder": folder,
|
||||
"current_sort": sort or "path",
|
||||
"sync_result": sync_result,
|
||||
"context_type": context_type or "",
|
||||
"context_id": context_id or "",
|
||||
|
||||
@@ -16,41 +16,70 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if folders %}
|
||||
<div class="filter-bar" style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px;">
|
||||
<a href="/files" class="btn btn-xs {{ 'btn-primary' if current_folder is none else 'btn-ghost' }}">All</a>
|
||||
<a href="/files?folder=" class="btn btn-xs {{ 'btn-primary' if current_folder is not none and current_folder == '' else 'btn-ghost' }}">/</a>
|
||||
{% for f in folders %}
|
||||
<a href="/files?folder={{ f }}" class="btn btn-xs {{ 'btn-primary' if current_folder == f else 'btn-ghost' }}">{{ f }}</a>
|
||||
{% endfor %}
|
||||
<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>
|
||||
{% for f in folders %}
|
||||
<option value="/files?folder={{ f }}" {{ 'selected' if current_folder == f }}>{% if '/' in f %} {{ f.split('/')[-1] }}{% else %}{{ f }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% set sort_base = '/files?' ~ ('folder=' ~ current_folder ~ '&' if current_folder is not none else '') %}
|
||||
|
||||
{% if items %}
|
||||
<div class="card">
|
||||
{% for item in items %}
|
||||
<div class="list-row">
|
||||
<span class="row-title">
|
||||
<a href="/files/{{ item.id }}/preview">{{ item.original_filename }}</a>
|
||||
</span>
|
||||
<span class="row-meta" style="color: var(--muted); font-size: 0.8rem;">{{ item.folder }}</span>
|
||||
{% if item.mime_type %}
|
||||
<span class="row-tag">{{ item.mime_type.split('/')|last }}</span>
|
||||
{% endif %}
|
||||
{% if item.size_bytes %}
|
||||
<span class="row-meta">{{ "%.1f"|format(item.size_bytes / 1024) }} KB</span>
|
||||
{% endif %}
|
||||
{% if item.description %}
|
||||
<span class="row-meta">{{ item.description[:50] }}</span>
|
||||
{% endif %}
|
||||
<div class="row-actions">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="card" style="overflow-x: auto;">
|
||||
<table class="data-table" style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid var(--border); text-align: left;">
|
||||
<th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
<a href="{{ sort_base }}sort={{ 'path_desc' if current_sort == 'path' else 'path' }}" style="color: var(--muted); text-decoration: none;">
|
||||
Path {{ '▲' if current_sort == 'path' else ('▼' if current_sort == 'path_desc' else '') }}
|
||||
</a>
|
||||
</th>
|
||||
<th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
<a href="{{ sort_base }}sort={{ 'name_desc' if current_sort == 'name' else 'name' }}" style="color: var(--muted); text-decoration: none;">
|
||||
Name {{ '▲' if current_sort == 'name' else ('▼' if current_sort == 'name_desc' else '') }}
|
||||
</a>
|
||||
</th>
|
||||
<th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">Type</th>
|
||||
<th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">Size</th>
|
||||
<th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
<a href="{{ sort_base }}sort={{ 'date_asc' if current_sort == 'date' else 'date' }}" style="color: var(--muted); text-decoration: none;">
|
||||
Date {{ '▼' if current_sort == 'date' else ('▲' if current_sort == 'date_asc' else '') }}
|
||||
</a>
|
||||
</th>
|
||||
<th style="padding: 10px 12px;"></th>
|
||||
</tr>
|
||||
</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>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
|
||||
Reference in New Issue
Block a user