Scrollable upload grid and bulk rating for the pending backlog
Upload board: - the tile grid no longer re-sorts itself as files finish (that reshuffled the list under the cursor); it keeps insertion order, uses auto-fill tiles of ~150px so they hold a readable size, scrolls inside a 60vh area and no longer chains the page scroll (overscroll-contain); - the files currently in flight are pinned in a small live strip above the grid (name, percent, bar) so progress stays visible while the grid is scrolled with hundreds of tiles. Bulk rating: a 'bulk rate' button in the Pending & Unmatched header opens a large modal with Safe/Questionable/Explicit pills, a tickable thumbnail grid (Select all / Clear) and one confirm that moves every selected upload into the library with that rating. Backed by POST /api/uploads/resolve-bulk/ (temp_ids + rating, own rows only): each staged file is resolved as a custom entry (keeps its staged tags/notes), and already-completed or foreign ids are reported per entry instead of failing the whole batch. Built for the 358-file backlog. Tests: 4 bulk-resolve tests (resolution with the rating, input validation, foreign ids untouched, mixed completed+pending) — full backend suite 53 green. Verified live end to end: staged a file, bulk-resolved it as 'q', saw J-96 created with that rating, then removed the item, temp row and test token.
This commit is contained in:
@@ -1,21 +1,31 @@
|
|||||||
"""The staged-upload list is a complete set, not a page.
|
"""Staged uploads: complete-set listing and the bulk rating tool."""
|
||||||
|
|
||||||
The upload board partitions it into Pending / Visual similarity / Auto-uploaded
|
|
||||||
columns; with the standard 48-item pagination a 69-file batch silently lost 21
|
|
||||||
entries from the board (and from the similarity sweep that reads it back).
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import Client, TestCase
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import Client, TestCase, override_settings
|
||||||
|
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from apps.library.models import TempUpload
|
from apps.library.models import MediaItem, TempUpload
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
# 1x1 transparent PNG so indexing/hashing has a real image to chew on.
|
||||||
|
TINY_PNG = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def jpost(client, path, body=None):
|
||||||
|
return client.post(path, data=json.dumps(body or {}), content_type="application/json")
|
||||||
|
|
||||||
|
|
||||||
class TempUploadListTests(TestCase):
|
class TempUploadListTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -73,3 +83,115 @@ class TempUploadListTests(TestCase):
|
|||||||
|
|
||||||
def test_anonymous_cannot_list(self):
|
def test_anonymous_cannot_list(self):
|
||||||
self.assertEqual(Client().get("/api/uploads/").status_code, 401)
|
self.assertEqual(Client().get("/api/uploads/").status_code, 401)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkResolveTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls._tmp = tempfile.mkdtemp(prefix="j621-bulk-")
|
||||||
|
cls._watched = Path(cls._tmp) / "library"
|
||||||
|
cls._watched.mkdir(parents=True, exist_ok=True)
|
||||||
|
cls._settings = override_settings(
|
||||||
|
MEDIA_ROOT=cls._tmp, WATCHED_FOLDER=str(cls._watched)
|
||||||
|
)
|
||||||
|
cls._settings.enable()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls._settings.disable()
|
||||||
|
shutil.rmtree(cls._tmp, ignore_errors=True)
|
||||||
|
super().tearDownClass()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.uploader = User.objects.create_user(
|
||||||
|
username="bulk-uploader", password="bulk-pass-123456"
|
||||||
|
)
|
||||||
|
self.uploader.role = "uploader"
|
||||||
|
self.uploader.save(update_fields=["role"])
|
||||||
|
self.other = User.objects.create_user(
|
||||||
|
username="bulk-other", password="bulk-pass-123456"
|
||||||
|
)
|
||||||
|
self.other.role = "uploader"
|
||||||
|
self.other.save(update_fields=["role"])
|
||||||
|
|
||||||
|
def api_client(self, user):
|
||||||
|
client = Client()
|
||||||
|
client.defaults["HTTP_AUTHORIZATION"] = (
|
||||||
|
f"Token {Token.objects.create(user=user).key}"
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
def make_temp(self, user, label, status=TempUpload.STATUS_PENDING):
|
||||||
|
return TempUpload.objects.create(
|
||||||
|
user=user,
|
||||||
|
file=SimpleUploadedFile(f"{label}.png", TINY_PNG, content_type="image/png"),
|
||||||
|
original_filename=f"{label}.png",
|
||||||
|
md5=hashlib.md5(label.encode()).hexdigest(),
|
||||||
|
size=len(TINY_PNG),
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_bulk(self, client, ids, rating):
|
||||||
|
return jpost(
|
||||||
|
client,
|
||||||
|
"/api/uploads/resolve-bulk/",
|
||||||
|
{"temp_ids": [str(value) for value in ids], "rating": rating},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolves_selected_uploads_with_the_rating(self):
|
||||||
|
client = self.api_client(self.uploader)
|
||||||
|
first = self.make_temp(self.uploader, "one")
|
||||||
|
second = self.make_temp(self.uploader, "two")
|
||||||
|
untouched = self.make_temp(self.uploader, "three")
|
||||||
|
|
||||||
|
response = self.resolve_bulk(client, [first.id, second.id], "q")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual(len(body["resolved"]), 2)
|
||||||
|
self.assertEqual(body["errors"], [])
|
||||||
|
|
||||||
|
for temp in (first, second):
|
||||||
|
temp.refresh_from_db()
|
||||||
|
self.assertEqual(temp.status, TempUpload.STATUS_COMPLETED)
|
||||||
|
self.assertIsNotNone(temp.library_item_id)
|
||||||
|
self.assertEqual(temp.library_item.rating, "q")
|
||||||
|
self.assertEqual(temp.library_item.uploaded_by_id, self.uploader.id)
|
||||||
|
untouched.refresh_from_db()
|
||||||
|
self.assertEqual(untouched.status, TempUpload.STATUS_PENDING)
|
||||||
|
|
||||||
|
def test_rejects_bad_input(self):
|
||||||
|
client = self.api_client(self.uploader)
|
||||||
|
temp = self.make_temp(self.uploader, "input")
|
||||||
|
self.assertEqual(self.resolve_bulk(client, [], "s").status_code, 400)
|
||||||
|
self.assertEqual(self.resolve_bulk(client, [temp.id], "").status_code, 400)
|
||||||
|
self.assertEqual(self.resolve_bulk(client, [temp.id], "x").status_code, 400)
|
||||||
|
self.assertEqual(
|
||||||
|
jpost(
|
||||||
|
client,
|
||||||
|
"/api/uploads/resolve-bulk/",
|
||||||
|
{"temp_ids": ["not-a-uuid"], "rating": "s"},
|
||||||
|
).status_code,
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_other_users_uploads_are_left_alone(self):
|
||||||
|
client = self.api_client(self.uploader)
|
||||||
|
theirs = self.make_temp(self.other, "theirs")
|
||||||
|
response = self.resolve_bulk(client, [theirs.id], "s")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual(body["resolved"], [])
|
||||||
|
self.assertEqual(body["errors"][0]["error"], "not found")
|
||||||
|
theirs.refresh_from_db()
|
||||||
|
self.assertEqual(theirs.status, TempUpload.STATUS_PENDING)
|
||||||
|
|
||||||
|
def test_completed_uploads_report_an_error_but_others_resolve(self):
|
||||||
|
client = self.api_client(self.uploader)
|
||||||
|
done = self.make_temp(self.uploader, "done", status=TempUpload.STATUS_COMPLETED)
|
||||||
|
pending = self.make_temp(self.uploader, "pending")
|
||||||
|
response = self.resolve_bulk(client, [done.id, pending.id], "e")
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual(len(body["resolved"]), 1)
|
||||||
|
self.assertEqual(body["errors"][0]["error"], "already in the library")
|
||||||
|
self.assertEqual(MediaItem.objects.count(), 1)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from urllib.parse import urlparse
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import mixins, status, viewsets
|
from rest_framework import mixins, status, viewsets
|
||||||
@@ -328,6 +329,64 @@ class TempUploadViewSet(
|
|||||||
temp.refresh_from_db()
|
temp.refresh_from_db()
|
||||||
return Response(self.get_serializer(temp).data)
|
return Response(self.get_serializer(temp).data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], url_path="resolve-bulk")
|
||||||
|
def resolve_bulk(self, request):
|
||||||
|
"""Move many staged uploads into the library with one rating.
|
||||||
|
|
||||||
|
The upload board's bulk tool: pick a rating, tick pending files, and
|
||||||
|
they are all resolved as custom entries (their staged tags/notes are
|
||||||
|
kept). Own rows only, like the rest of the viewset.
|
||||||
|
"""
|
||||||
|
ids = request.data.get("temp_ids")
|
||||||
|
if not isinstance(ids, list) or not ids:
|
||||||
|
return Response(
|
||||||
|
{"detail": "temp_ids must be a non-empty list."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
rating = str(request.data.get("rating") or "").strip()
|
||||||
|
if rating not in {"s", "q", "e"}:
|
||||||
|
return Response(
|
||||||
|
{"detail": "rating must be one of s, q or e."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
queryset = self.get_queryset().filter(
|
||||||
|
pk__in=[str(value) for value in ids]
|
||||||
|
)
|
||||||
|
except (ValidationError, ValueError):
|
||||||
|
return Response(
|
||||||
|
{"detail": "One or more ids are not valid upload ids."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved: list[str] = []
|
||||||
|
errors: list[dict[str, str]] = []
|
||||||
|
for temp in queryset:
|
||||||
|
if temp.status == TempUpload.STATUS_COMPLETED:
|
||||||
|
errors.append(
|
||||||
|
{"temp_id": str(temp.id), "error": "already in the library"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
temp.custom_rating = rating
|
||||||
|
temp.resolution = TempUpload.RESOLUTION_CUSTOM
|
||||||
|
temp.save(update_fields=["custom_rating", "resolution", "updated_at"])
|
||||||
|
try:
|
||||||
|
complete_temp_upload(temp)
|
||||||
|
except Exception as exc: # noqa: BLE001 - report per-file failures
|
||||||
|
logger.exception("Could not complete staged upload %s", temp.id)
|
||||||
|
temp.status = TempUpload.STATUS_ERROR
|
||||||
|
temp.save(update_fields=["status", "updated_at"])
|
||||||
|
errors.append({"temp_id": str(temp.id), "error": str(exc)})
|
||||||
|
continue
|
||||||
|
resolved.append(str(temp.id))
|
||||||
|
|
||||||
|
found = {str(temp.id) for temp in queryset}
|
||||||
|
for value in sorted({str(value) for value in ids} - found):
|
||||||
|
errors.append({"temp_id": value, "error": "not found"})
|
||||||
|
|
||||||
|
return Response({"resolved": resolved, "errors": errors})
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
if instance.file:
|
if instance.file:
|
||||||
instance.file.delete(save=False)
|
instance.file.delete(save=False)
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check, Video, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Button, Spinner } from "@/components/ui";
|
||||||
|
import { api, errorMessage } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import type { TempUpload } from "@/lib/types";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
|
const RATING_OPTIONS = [
|
||||||
|
{
|
||||||
|
value: "s",
|
||||||
|
label: "Safe",
|
||||||
|
active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "q",
|
||||||
|
label: "Questionable",
|
||||||
|
active: "border-ctp-peach/40 bg-ctp-peach/15 text-ctp-peach",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "e",
|
||||||
|
label: "Explicit",
|
||||||
|
active: "border-ctp-red/40 bg-ctp-red/15 text-ctp-red",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface BulkResult {
|
||||||
|
resolved: string[];
|
||||||
|
errors: { temp_id: string; error: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-rating tool for the Pending & Unmatched column: pick a rating, tick
|
||||||
|
* every thumbnail that belongs to it, and they all move into the library as
|
||||||
|
* custom entries in one request.
|
||||||
|
*/
|
||||||
|
export function BulkRateModal({
|
||||||
|
uploads,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
uploads: TempUpload[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [rating, setRating] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (payload: { temp_ids: string[]; rating: string }) =>
|
||||||
|
api<BulkResult>("/api/uploads/resolve-bulk/", {
|
||||||
|
method: "POST",
|
||||||
|
json: payload,
|
||||||
|
}),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["uploads"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||||
|
if (result.resolved.length > 0) {
|
||||||
|
toast.ok(`Moved ${result.resolved.length} upload(s) into the library.`);
|
||||||
|
}
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
toast.error(
|
||||||
|
`${result.errors.length} could not be resolved: ${result.errors[0].error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(errorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
setSelected((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[70] flex items-start justify-center overflow-y-auto bg-ctp-crust/70 px-4 py-8 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-ctp-surface1 bg-ctp-base"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="flex items-start justify-between gap-3 border-b border-ctp-surface0 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-ctp-text">
|
||||||
|
Rate unmatched uploads
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-ctp-overlay0">
|
||||||
|
Pick a rating, tick every file that belongs to it, and they move
|
||||||
|
into the library in one go ({uploads.length} pending).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
title="Close"
|
||||||
|
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b border-ctp-surface0 px-4 py-2.5">
|
||||||
|
{RATING_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRating(option.value)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3 py-1 text-xs font-medium transition",
|
||||||
|
rating === option.value
|
||||||
|
? option.active
|
||||||
|
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="ml-auto flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
onClick={() =>
|
||||||
|
setSelected(new Set(uploads.map((temp) => temp.temp_id)))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Select all
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
disabled={selected.size === 0}
|
||||||
|
onClick={() => setSelected(new Set())}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto overscroll-contain p-4">
|
||||||
|
{uploads.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-ctp-overlay0">
|
||||||
|
Nothing pending.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] content-start gap-2">
|
||||||
|
{uploads.map((temp) => {
|
||||||
|
const isSelected = selected.has(temp.temp_id);
|
||||||
|
const preview = temp.preview_url ?? temp.file_url;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={temp.temp_id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(temp.temp_id)}
|
||||||
|
title={temp.original_filename}
|
||||||
|
className={cn(
|
||||||
|
"relative overflow-hidden rounded-md border bg-ctp-mantle text-left transition",
|
||||||
|
isSelected
|
||||||
|
? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
|
||||||
|
: "border-ctp-surface0 hover:border-ctp-surface2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block aspect-square overflow-hidden">
|
||||||
|
{preview ? (
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt={temp.original_filename}
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-full w-full items-center justify-center">
|
||||||
|
<Video className="h-5 w-5 text-ctp-overlay0" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{isSelected ? (
|
||||||
|
<span className="absolute left-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-ctp-mauve text-ctp-crust">
|
||||||
|
<Check className="h-3 w-3" strokeWidth={3} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="block truncate px-1 py-0.5 font-mono text-[10px] text-ctp-overlay0">
|
||||||
|
{temp.original_filename}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-ctp-surface0 px-4 py-3">
|
||||||
|
<span className="font-mono text-[11px] text-ctp-overlay0">
|
||||||
|
{selected.size} selected
|
||||||
|
{rating ? "" : " · pick a rating"}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={selected.size === 0 || !rating || mutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!rating) return;
|
||||||
|
mutation.mutate({ temp_ids: [...selected], rating });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="h-3.5 w-3.5" /> Moving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`Move ${selected.size} into the library`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ListChecks,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
Video,
|
Video,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
||||||
|
import { BulkRateModal } from "@/features/upload/BulkRateModal";
|
||||||
import { api, authHeaders, apiUrl, errorMessage, uploadFile } from "@/lib/api";
|
import { api, authHeaders, apiUrl, errorMessage, uploadFile } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import {
|
import {
|
||||||
@@ -39,14 +40,6 @@ type UploadStatus = "queued" | "uploading" | "done" | "error";
|
|||||||
/** How many files upload at once; big batches were painfully serial. */
|
/** How many files upload at once; big batches were painfully serial. */
|
||||||
const UPLOAD_CONCURRENCY = 3;
|
const UPLOAD_CONCURRENCY = 3;
|
||||||
|
|
||||||
/** Display order: the file being uploaded stays at the top of the grid. */
|
|
||||||
const STATUS_ORDER: Record<UploadStatus, number> = {
|
|
||||||
uploading: 0,
|
|
||||||
queued: 1,
|
|
||||||
error: 2,
|
|
||||||
done: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UploadEntry {
|
interface UploadEntry {
|
||||||
id: string;
|
id: string;
|
||||||
file: File;
|
file: File;
|
||||||
@@ -639,6 +632,7 @@ export default function UploadPage() {
|
|||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const [checkingIds, setCheckingIds] = useState<string[]>([]);
|
const [checkingIds, setCheckingIds] = useState<string[]>([]);
|
||||||
const [modalFor, setModalFor] = useState<TempUpload | null>(null);
|
const [modalFor, setModalFor] = useState<TempUpload | null>(null);
|
||||||
|
const [bulkOpen, setBulkOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urls = objectUrls.current;
|
const urls = objectUrls.current;
|
||||||
@@ -661,13 +655,10 @@ export default function UploadPage() {
|
|||||||
(temp) => temp.status === "completed" || temp.status === "error",
|
(temp) => temp.status === "completed" || temp.status === "error",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Active work first so a long batch never hides the file in progress.
|
// Active work stays in insertion order (the grid must not reshuffle while
|
||||||
const orderedEntries = useMemo(
|
// you scroll it); the in-flight files get their own strip above it.
|
||||||
() =>
|
const uploadingEntries = entries.filter(
|
||||||
[...entries].sort(
|
(entry) => entry.status === "uploading",
|
||||||
(a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status],
|
|
||||||
),
|
|
||||||
[entries],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadedCount = entries.filter((entry) => entry.status === "done").length;
|
const uploadedCount = entries.filter((entry) => entry.status === "done").length;
|
||||||
@@ -1023,8 +1014,34 @@ export default function UploadPage() {
|
|||||||
{batchPercent}%
|
{batchPercent}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid max-h-[46rem] grid-cols-2 gap-3 overflow-y-auto pr-1 sm:grid-cols-3 lg:grid-cols-4">
|
{uploadingEntries.length > 0 ? (
|
||||||
{orderedEntries.map((entry) => (
|
<div className="flex flex-col gap-1.5 rounded-lg border border-ctp-surface0 bg-ctp-base p-2">
|
||||||
|
{uploadingEntries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-center gap-2 text-[11px]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-40 shrink-0 truncate font-mono text-ctp-subtext1 sm:w-56"
|
||||||
|
title={entry.file.name}
|
||||||
|
>
|
||||||
|
{entry.file.name}
|
||||||
|
</span>
|
||||||
|
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-ctp-surface0">
|
||||||
|
<div
|
||||||
|
className="h-full bg-ctp-mauve transition-[width] duration-150"
|
||||||
|
style={{ width: `${entry.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-9 shrink-0 text-right font-mono text-ctp-overlay0">
|
||||||
|
{entry.progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="grid max-h-[60vh] grid-cols-[repeat(auto-fill,minmax(150px,1fr))] content-start gap-3 overflow-y-auto overscroll-contain pr-1">
|
||||||
|
{entries.map((entry) => (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base"
|
className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base"
|
||||||
@@ -1081,7 +1098,22 @@ export default function UploadPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
{column("Pending & Unmatched", pending, "Nothing waiting.")}
|
{column(
|
||||||
|
"Pending & Unmatched",
|
||||||
|
pending,
|
||||||
|
"Nothing waiting.",
|
||||||
|
pending.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="px-2 py-0.5 text-[11px]"
|
||||||
|
onClick={() => setBulkOpen(true)}
|
||||||
|
title="Pick a rating and move a bunch of pending uploads into the library"
|
||||||
|
>
|
||||||
|
<ListChecks className="h-3.5 w-3.5" />
|
||||||
|
bulk rate
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
{column(
|
{column(
|
||||||
"Visual Similarity Detected",
|
"Visual Similarity Detected",
|
||||||
visual,
|
visual,
|
||||||
@@ -1107,6 +1139,10 @@ export default function UploadPage() {
|
|||||||
{modalFor ? (
|
{modalFor ? (
|
||||||
<MetadataModal temp={modalFor} onClose={() => setModalFor(null)} />
|
<MetadataModal temp={modalFor} onClose={() => setModalFor(null)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{bulkOpen ? (
|
||||||
|
<BulkRateModal uploads={pending} onClose={() => setBulkOpen(false)} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user