diff --git a/links/api_views.py b/links/api_views.py index 90a589c..1a7f719 100644 --- a/links/api_views.py +++ b/links/api_views.py @@ -5,7 +5,7 @@ from rest_framework.pagination import PageNumberPagination from .models import Link, ImageCollection, Image, FileUpload from .serializers import LinkSerializer, ImageCollectionSerializer, ImageSerializer, ImageDescriptionSerializer from .storage import R2Storage -from .file_views import _save_uploaded_file, FileSaveError +from .file_views import _save_uploaded_file, FileSaveError, _generate_thumbnail import uuid import logging import os @@ -125,6 +125,8 @@ class ImageCollectionViewSet(viewsets.ModelViewSet): # Persist to the files backend (FILE_UPLOADS_FOLDER + FileUpload record) try: upload = _save_uploaded_file(file) + # Pre-generate thumbnail so the first grid view is instant + _generate_thumbnail(upload) except FileSaveError as exc: logger.error(f"Storage upload failed: {exc}", exc_info=True) raise ValueError(f"Storage upload failed: {exc}") diff --git a/links/file_views.py b/links/file_views.py index bc2a361..edda014 100644 --- a/links/file_views.py +++ b/links/file_views.py @@ -101,6 +101,12 @@ def _stream_file(file_path, mime_type, disposition, range_header=None): so we parse the header ourselves and reply with ``206 Partial Content`` and a ``Content-Range`` header. Without this, the media element can only play sequentially and seeking fails. + + Files stored via FileUpload get a random hex ``stored_name`` that never + changes for a given pk, so inline media responses are safe to cache long + term (``public, max-age=31536000, immutable``) — this keeps repeated views + off the (possibly small) NFS-backed uploads volume. Attachment downloads + are marked no-store. """ size = os.path.getsize(file_path) @@ -110,6 +116,10 @@ def _stream_file(file_path, mime_type, disposition, range_header=None): response['Content-Length'] = size response['Accept-Ranges'] = 'bytes' response['Content-Disposition'] = disposition + if 'inline' in disposition: + response['Cache-Control'] = 'public, max-age=31536000, immutable' + else: + response['Cache-Control'] = 'private, no-store' return response # Parse "bytes=start-end" (end optional; multiple ranges not supported here). @@ -156,6 +166,10 @@ def _stream_file(file_path, mime_type, disposition, range_header=None): response['Content-Range'] = f'bytes {start}-{end}/{size}' response['Accept-Ranges'] = 'bytes' response['Content-Disposition'] = disposition + if 'inline' in disposition: + response['Cache-Control'] = 'public, max-age=31536000, immutable' + else: + response['Cache-Control'] = 'private, no-store' # Ensure the file handle is closed when the response finalises. response.close = fh.close return response @@ -237,7 +251,10 @@ class FileThumbnailView(View): thumb = _generate_thumbnail(record) if not thumb or not os.path.exists(thumb): raise Http404('No thumbnail available for this file') - return FileResponse(open(thumb, 'rb'), content_type='image/jpeg') + response = FileResponse(open(thumb, 'rb'), content_type='image/jpeg') + # Thumbnail path is derived from the immutable stored_name → cache forever. + response['Cache-Control'] = 'public, max-age=31536000, immutable' + return response class FileListView(View): @@ -259,6 +276,10 @@ class FileUploadView(View): for f in uploaded: try: record = _save_uploaded_file(f) + # Pre-generate the thumbnail for images so the first page view + # is instant (and the NFS volume isn't hammered by lazy gen). + if record.is_image: + _generate_thumbnail(record) except FileSaveError as exc: logger.error("Upload failed for %s: %s", f.name, exc) errors.append({'name': f.name, 'error': str(exc)}) @@ -399,7 +420,12 @@ class FileUploadViewSet(viewsets.ModelViewSet): errors = [] for f in uploaded: try: - created.append(_save_uploaded_file(f)) + record = _save_uploaded_file(f) + # Pre-generate the thumbnail for images so the first page view + # is instant (and the NFS volume isn't hammered by lazy gen). + if record.is_image: + _generate_thumbnail(record) + created.append(record) except FileSaveError as exc: logger.error("Upload failed for %s: %s", f.name, exc) errors.append({'name': f.name, 'error': str(exc)}) diff --git a/links/templates/links/files/list.html b/links/templates/links/files/list.html index c336b49..b2e9a89 100644 --- a/links/templates/links/files/list.html +++ b/links/templates/links/files/list.html @@ -162,6 +162,32 @@ .layout-btn { width: 38px; height: 32px; } } + /* ---------- Type filter bar ---------- */ + .filter-bar { + display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; + padding: 0.1rem 1.8rem 1.1rem; + } + .filter-chip { + display: inline-flex; align-items: center; gap: 0.4rem; + min-height: 32px; padding: 0.3rem 0.85rem; + border-radius: 980px; border: 1px solid rgba(0, 0, 0, 0.07); + background: #fff; color: var(--apple-gray); + font-size: 0.8rem; font-weight: 600; cursor: pointer; + transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease, transform 0.2s var(--apple-ease); + } + .filter-chip:hover { background: #f0f0f3; } + .filter-chip:active { transform: scale(0.96); } + .filter-chip.active { + background: var(--apple-blue); color: #fff; border-color: var(--apple-blue); + } + .chip-count { + display: inline-flex; align-items: center; justify-content: center; + min-width: 20px; height: 20px; padding: 0 0.35rem; + border-radius: 980px; font-size: 0.7rem; font-weight: 700; + background: rgba(0, 0, 0, 0.06); color: var(--apple-gray); + } + .filter-chip.active .chip-count { background: rgba(255, 255, 255, 0.24); color: #fff; } + /* ---------- Grid view (macOS Finder style) ---------- */ .file-grid { display: grid; gap: 0.9rem; padding: 0.6rem 1.8rem 1.6rem; @@ -450,7 +476,7 @@

{% trans "Files" %}

- {{ files.count }} + {% trans "files" %}

@@ -491,6 +517,21 @@ + +
+ + +
+