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]
|
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 "",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<option value="/files?folder=" {{ 'selected' if current_folder is not none and current_folder == '' }}>/ (root)</option>
|
||||||
{% for f in folders %}
|
{% for f in folders %}
|
||||||
<a href="/files?folder={{ f }}" class="btn btn-xs {{ 'btn-primary' if current_folder == f else 'btn-ghost' }}">{{ f }}</a>
|
<option value="/files?folder={{ f }}" {{ 'selected' if current_folder == f }}>{% if '/' in f %} {{ f.split('/')[-1] }}{% else %}{{ f }}{% endif %}</option>
|
||||||
{% endfor %}
|
{% 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;">
|
||||||
|
<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 %}
|
{% for item in items %}
|
||||||
<div class="list-row">
|
<tr style="border-bottom: 1px solid var(--border);">
|
||||||
<span class="row-title">
|
<td style="padding: 10px 12px; color: var(--muted); font-size: 0.85rem;">{{ item.folder }}</td>
|
||||||
<a href="/files/{{ item.id }}/preview">{{ item.original_filename }}</a>
|
<td style="padding: 10px 12px;">
|
||||||
</span>
|
<a href="/files/{{ item.id }}/preview" style="color: var(--accent);">{{ item.original_filename }}</a>
|
||||||
<span class="row-meta" style="color: var(--muted); font-size: 0.8rem;">{{ item.folder }}</span>
|
</td>
|
||||||
{% if item.mime_type %}
|
<td style="padding: 10px 12px;">
|
||||||
<span class="row-tag">{{ item.mime_type.split('/')|last }}</span>
|
{% if item.mime_type %}<span class="row-tag">{{ item.mime_type.split('/')|last }}</span>{% endif %}
|
||||||
{% endif %}
|
</td>
|
||||||
{% if item.size_bytes %}
|
<td style="padding: 10px 12px; color: var(--muted); font-size: 0.85rem; white-space: nowrap;">
|
||||||
<span class="row-meta">{{ "%.1f"|format(item.size_bytes / 1024) }} KB</span>
|
{% if item.size_bytes %}{{ "%.1f"|format(item.size_bytes / 1024) }} KB{% endif %}
|
||||||
{% endif %}
|
</td>
|
||||||
{% if item.description %}
|
<td style="padding: 10px 12px; color: var(--muted); font-size: 0.85rem; white-space: nowrap;">
|
||||||
<span class="row-meta">{{ item.description[:50] }}</span>
|
{{ item.created_at.strftime('%Y-%m-%d') if item.created_at else '' }}
|
||||||
{% endif %}
|
</td>
|
||||||
<div class="row-actions">
|
<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>
|
<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">
|
<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>
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
|
|||||||
Reference in New Issue
Block a user