Shell shortcuts: /, D, [ ], Ctrl+K palette + neighbors endpoint

Backend:
- GET /api/files/{md5}/neighbors/ returns previous/next items in the
  current ordering (name, size, created_at) for keyboard navigation

Frontend:
- / focuses the library search input
- D downloads the file on the detail page
- [ and ] navigate to the previous/next item; hint shown on the page
- Ctrl/Cmd+K opens a command palette (navigation, toggle filters,
  focus search, log in/out); Escape closes it
- Sort order now persists alongside the other library filters so
  prev/next stays consistent
This commit is contained in:
2026-09-17 08:33:22 -05:00
parent 063305c552
commit a09ab8a160
8 changed files with 324 additions and 10 deletions
+46 -2
View File
@@ -13,6 +13,8 @@ from . import services
from .models import MediaItem
from .serializers import MediaItemSerializer
LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"}
class MediaItemViewSet(
mixins.ListModelMixin,
@@ -39,8 +41,9 @@ class MediaItemViewSet(
if ratings:
queryset = queryset.filter(rating__in=ratings)
ordering = self.request.query_params.get("ordering", "").strip()
allowed = {"name", "-name", "size", "-size", "created_at", "-created_at"}
queryset = queryset.order_by(ordering if ordering in allowed else "-created_at")
queryset = queryset.order_by(
ordering if ordering in LIST_ORDERINGS else "-created_at"
)
return queryset.distinct()
@action(detail=True, methods=["get"])
@@ -76,6 +79,47 @@ class MediaItemViewSet(
return services.serve_file(request, thumbnail)
return services.serve_file(request, path)
@action(detail=True, methods=["get"])
def neighbors(self, request, md5=None):
"""Previous/next items in the ordered list, for keyboard navigation."""
item = self.get_object()
ordering = request.query_params.get("ordering", "-created_at").strip()
if ordering not in LIST_ORDERINGS:
ordering = "-created_at"
field = ordering.lstrip("-")
descending = ordering.startswith("-")
value = {
"size": item.size,
"created_at": item.created_at,
"name": item.name,
}[field]
queryset = self.get_queryset().exclude(pk=item.pk)
if descending:
previous_query = queryset.filter(**{f"{field}__gt": value}).order_by(field)
next_query = queryset.filter(**{f"{field}__lt": value}).order_by(f"-{field}")
else:
previous_query = queryset.filter(**{f"{field}__lt": value}).order_by(
f"-{field}"
)
next_query = queryset.filter(**{f"{field}__gt": value}).order_by(field)
def brief(obj):
location = obj.locations.first()
return {
"md5": obj.md5,
"filename": Path(location.rel_path).name if location else obj.md5,
}
previous = previous_query.first()
next_item = next_query.first()
return Response(
{
"previous": brief(previous) if previous else None,
"next": brief(next_item) if next_item else None,
}
)
class UploadView(APIView):
permission_classes = [IsAuthenticated]