Library search upgrades: tag search, tag cloud, status filter
Backend: - MediaItem gains search_tags (custom + e621 tags, lowercase) and has_custom_data, maintained on save with a data migration backfill - File list search accepts search_type=filename|tags|both (tag search is word-AND across the flattened tag text) and status=matched|custom| unknown filters - New /api/tags/cloud/ endpoint (cached 2 min per guest/auth, invalidated on item changes and deletions) returning the most-used tags, honouring guest visibility Frontend: - Library sidebar: Filename/Tags/Both selector, status pill toggles (persisted), and a clickable tag cloud that runs a tag search - Roadmap updated
This commit is contained in:
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 6.1.1 on 2026-09-17 18:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_derived_fields(apps, schema_editor):
|
||||||
|
MediaItem = apps.get_model("library", "MediaItem")
|
||||||
|
for item in MediaItem.objects.all().iterator(chunk_size=200):
|
||||||
|
names = [str(tag) for tag in (item.tags or [])]
|
||||||
|
data = item.e621_data if isinstance(item.e621_data, dict) else {}
|
||||||
|
categories = data.get("tags")
|
||||||
|
if isinstance(categories, dict):
|
||||||
|
for values in categories.values():
|
||||||
|
if isinstance(values, list):
|
||||||
|
names.extend(str(tag) for tag in values)
|
||||||
|
search_tags = " ".join(
|
||||||
|
sorted({str(name).strip().lower() for name in names if str(name).strip()})
|
||||||
|
)
|
||||||
|
MediaItem.objects.filter(pk=item.pk).update(
|
||||||
|
search_tags=search_tags,
|
||||||
|
has_custom_data=bool(item.tags or item.notes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0007_tempupload_visual_matches'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='mediaitem',
|
||||||
|
name='has_custom_data',
|
||||||
|
field=models.BooleanField(db_index=True, default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='mediaitem',
|
||||||
|
name='search_tags',
|
||||||
|
field=models.TextField(blank=True, default=''),
|
||||||
|
),
|
||||||
|
migrations.RunPython(backfill_derived_fields, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -4,6 +4,26 @@ from django.db import models
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
TAG_CLOUD_CACHE_KEYS = [
|
||||||
|
"j621.library.tag_cloud.auth",
|
||||||
|
"j621.library.tag_cloud.guest",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_search_tags(tags, e621_data):
|
||||||
|
"""Lowercase tag text (custom + e621) used for library tag search."""
|
||||||
|
names = [str(tag) for tag in (tags or [])]
|
||||||
|
data = e621_data if isinstance(e621_data, dict) else {}
|
||||||
|
categories = data.get("tags")
|
||||||
|
if isinstance(categories, dict):
|
||||||
|
for values in categories.values():
|
||||||
|
if isinstance(values, list):
|
||||||
|
names.extend(str(tag) for tag in values)
|
||||||
|
return " ".join(
|
||||||
|
sorted({str(name).strip().lower() for name in names if str(name).strip()})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MediaItem(models.Model):
|
class MediaItem(models.Model):
|
||||||
"""A logical media file, identified by its MD5 fingerprint."""
|
"""A logical media file, identified by its MD5 fingerprint."""
|
||||||
|
|
||||||
@@ -29,6 +49,9 @@ class MediaItem(models.Model):
|
|||||||
dhash = models.CharField(max_length=32, blank=True, default="", db_index=True)
|
dhash = models.CharField(max_length=32, blank=True, default="", db_index=True)
|
||||||
phash = models.CharField(max_length=32, blank=True, default="", db_index=True)
|
phash = models.CharField(max_length=32, blank=True, default="", db_index=True)
|
||||||
whash = models.CharField(max_length=32, blank=True, default="", db_index=True)
|
whash = models.CharField(max_length=32, blank=True, default="", db_index=True)
|
||||||
|
# Derived search/filter helpers: flattened tag text and custom-data flag.
|
||||||
|
search_tags = models.TextField(blank=True, default="")
|
||||||
|
has_custom_data = models.BooleanField(default=False, db_index=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@@ -39,13 +62,22 @@ class MediaItem(models.Model):
|
|||||||
return f"J-{self.pk} ({self.md5})"
|
return f"J-{self.pk} ({self.md5})"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
from .guest_filter import item_is_hidden_for_guests
|
from .guest_filter import item_is_hidden_for_guests
|
||||||
|
|
||||||
self.hidden_from_guests = item_is_hidden_for_guests(self)
|
self.hidden_from_guests = item_is_hidden_for_guests(self)
|
||||||
|
self.has_custom_data = bool(self.tags or self.notes)
|
||||||
|
self.search_tags = build_search_tags(self.tags, self.e621_data)
|
||||||
update_fields = kwargs.get("update_fields")
|
update_fields = kwargs.get("update_fields")
|
||||||
if update_fields is not None:
|
if update_fields is not None:
|
||||||
kwargs["update_fields"] = set(update_fields) | {"hidden_from_guests"}
|
kwargs["update_fields"] = set(update_fields) | {
|
||||||
|
"hidden_from_guests",
|
||||||
|
"has_custom_data",
|
||||||
|
"search_tags",
|
||||||
|
}
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
cache.delete_many(TAG_CLOUD_CACHE_KEYS)
|
||||||
|
|
||||||
|
|
||||||
class MediaLocation(models.Model):
|
class MediaLocation(models.Model):
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ from django.conf import settings
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from .models import MediaItem, MediaLocation
|
from .models import TAG_CLOUD_CACHE_KEYS, MediaItem, MediaLocation
|
||||||
from .permissions import CanUpload
|
from .permissions import CanUpload
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -299,6 +299,7 @@ class DeleteFilesView(APIView):
|
|||||||
item.delete()
|
item.delete()
|
||||||
|
|
||||||
cache.delete(STORAGE_CACHE_KEY)
|
cache.delete(STORAGE_CACHE_KEY)
|
||||||
|
cache.delete_many(TAG_CLOUD_CACHE_KEYS)
|
||||||
|
|
||||||
for location in MediaLocation.objects.filter(
|
for location in MediaLocation.objects.filter(
|
||||||
id__in=location_ids
|
id__in=location_ids
|
||||||
@@ -338,6 +339,56 @@ class ClearTempView(APIView):
|
|||||||
return Response({"removed": removed})
|
return Response({"removed": removed})
|
||||||
|
|
||||||
|
|
||||||
|
class TagCloudView(APIView):
|
||||||
|
"""Most-used tags across the library (custom + e621 tags)."""
|
||||||
|
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
cache_key = (
|
||||||
|
"j621.library.tag_cloud.auth"
|
||||||
|
if request.user.is_authenticated
|
||||||
|
else "j621.library.tag_cloud.guest"
|
||||||
|
)
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return Response(cached)
|
||||||
|
|
||||||
|
queryset = MediaItem.objects.all()
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
queryset = queryset.filter(hidden_from_guests=False)
|
||||||
|
|
||||||
|
counts = {}
|
||||||
|
for item in queryset.only("tags", "e621_data").iterator(chunk_size=500):
|
||||||
|
names = set()
|
||||||
|
for tag in item.tags or []:
|
||||||
|
name = str(tag).strip().lower()
|
||||||
|
if name:
|
||||||
|
names.add(name)
|
||||||
|
data = item.e621_data or {}
|
||||||
|
categories = data.get("tags") if isinstance(data, dict) else None
|
||||||
|
if isinstance(categories, dict):
|
||||||
|
for values in categories.values():
|
||||||
|
if isinstance(values, list):
|
||||||
|
names.update(
|
||||||
|
str(tag).strip().lower()
|
||||||
|
for tag in values
|
||||||
|
if str(tag).strip()
|
||||||
|
)
|
||||||
|
for name in names:
|
||||||
|
counts[name] = counts.get(name, 0) + 1
|
||||||
|
|
||||||
|
ranked = sorted(counts.items(), key=lambda entry: (-entry[1], entry[0]))
|
||||||
|
payload = {
|
||||||
|
"count": len(counts),
|
||||||
|
"tags": [
|
||||||
|
{"tag": tag, "count": count} for tag, count in ranked[:120]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
cache.set(cache_key, payload, 120)
|
||||||
|
return Response(payload)
|
||||||
|
|
||||||
|
|
||||||
class StorageView(APIView):
|
class StorageView(APIView):
|
||||||
"""Disk usage for the watched folder, media root and temp uploads."""
|
"""Disk usage for the watched folder, media root and temp uploads."""
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from .tools import (
|
|||||||
DeleteFilesView,
|
DeleteFilesView,
|
||||||
ExactDuplicatesView,
|
ExactDuplicatesView,
|
||||||
StorageView,
|
StorageView,
|
||||||
|
TagCloudView,
|
||||||
VisualGroupsView,
|
VisualGroupsView,
|
||||||
VisualMatchesView,
|
VisualMatchesView,
|
||||||
)
|
)
|
||||||
@@ -37,5 +38,6 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path("delete/", DeleteFilesView.as_view(), name="delete_files"),
|
path("delete/", DeleteFilesView.as_view(), name="delete_files"),
|
||||||
path("temp/clear/", ClearTempView.as_view(), name="clear_temp"),
|
path("temp/clear/", ClearTempView.as_view(), name="clear_temp"),
|
||||||
|
path("tags/cloud/", TagCloudView.as_view(), name="tag_cloud"),
|
||||||
path("storage/", StorageView.as_view(), name="storage_info"),
|
path("storage/", StorageView.as_view(), name="storage_info"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
from django.db.models import Min
|
from django.db.models import Min, Q
|
||||||
from django.http import Http404, StreamingHttpResponse
|
from django.http import Http404, StreamingHttpResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -51,8 +51,20 @@ class MediaItemViewSet(
|
|||||||
numeric_ids.append(int(number))
|
numeric_ids.append(int(number))
|
||||||
queryset = queryset.filter(pk__in=numeric_ids)
|
queryset = queryset.filter(pk__in=numeric_ids)
|
||||||
search = self.request.query_params.get("search", "").strip()
|
search = self.request.query_params.get("search", "").strip()
|
||||||
|
search_type = self.request.query_params.get("search_type", "filename").strip()
|
||||||
if search:
|
if search:
|
||||||
queryset = queryset.filter(locations__rel_path__icontains=search)
|
words = [word for word in search.lower().split() if word]
|
||||||
|
if search_type == "tags":
|
||||||
|
for word in words:
|
||||||
|
queryset = queryset.filter(search_tags__icontains=word)
|
||||||
|
elif search_type == "both":
|
||||||
|
query = Q(locations__rel_path__icontains=search)
|
||||||
|
tag_query = Q()
|
||||||
|
for word in words:
|
||||||
|
tag_query &= Q(search_tags__icontains=word)
|
||||||
|
queryset = queryset.filter(query | tag_query)
|
||||||
|
else:
|
||||||
|
queryset = queryset.filter(locations__rel_path__icontains=search)
|
||||||
ratings = [
|
ratings = [
|
||||||
value
|
value
|
||||||
for value in self.request.query_params.get("rating", "").split(",")
|
for value in self.request.query_params.get("rating", "").split(",")
|
||||||
@@ -60,6 +72,25 @@ class MediaItemViewSet(
|
|||||||
]
|
]
|
||||||
if ratings:
|
if ratings:
|
||||||
queryset = queryset.filter(rating__in=ratings)
|
queryset = queryset.filter(rating__in=ratings)
|
||||||
|
statuses = []
|
||||||
|
for value in self.request.query_params.getlist("status"):
|
||||||
|
statuses.extend(
|
||||||
|
part.strip() for part in value.split(",") if part.strip()
|
||||||
|
)
|
||||||
|
if statuses:
|
||||||
|
status_query = Q()
|
||||||
|
for status_value in set(statuses):
|
||||||
|
if status_value == "custom":
|
||||||
|
status_query |= Q(has_custom_data=True)
|
||||||
|
elif status_value == "matched":
|
||||||
|
status_query |= Q(
|
||||||
|
has_custom_data=False, e621_post_id__isnull=False
|
||||||
|
)
|
||||||
|
elif status_value == "unknown":
|
||||||
|
status_query |= Q(
|
||||||
|
has_custom_data=False, e621_post_id__isnull=True
|
||||||
|
)
|
||||||
|
queryset = queryset.filter(status_query)
|
||||||
ordering = self.request.query_params.get("ordering", "").strip()
|
ordering = self.request.query_params.get("ordering", "").strip()
|
||||||
queryset = queryset.order_by(
|
queryset = queryset.order_by(
|
||||||
ordering if ordering in LIST_ORDERINGS else "-created_at"
|
ordering if ordering in LIST_ORDERINGS else "-created_at"
|
||||||
|
|||||||
@@ -41,13 +41,34 @@ const ratingFilters = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const statusFilters = [
|
||||||
|
{
|
||||||
|
value: "matched",
|
||||||
|
label: "Matched",
|
||||||
|
active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "custom",
|
||||||
|
label: "Custom",
|
||||||
|
active: "border-ctp-mauve/40 bg-ctp-mauve/15 text-ctp-mauve",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "unknown",
|
||||||
|
label: "Unknown",
|
||||||
|
active: "border-ctp-surface2 bg-ctp-surface0 text-ctp-subtext1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function LibraryPage() {
|
export default function LibraryPage() {
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [searchType, setSearchType] = useState("filename");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
const ratings = useUi((state) => state.ratings);
|
const ratings = useUi((state) => state.ratings);
|
||||||
const setRatings = useUi((state) => state.setRatings);
|
const setRatings = useUi((state) => state.setRatings);
|
||||||
|
const statuses = useUi((state) => state.statuses);
|
||||||
|
const setStatuses = useUi((state) => state.setStatuses);
|
||||||
const perPage = useUi((state) => state.perPage);
|
const perPage = useUi((state) => state.perPage);
|
||||||
const setPerPage = useUi((state) => state.setPerPage);
|
const setPerPage = useUi((state) => state.setPerPage);
|
||||||
const ordering = useUi((state) => state.ordering);
|
const ordering = useUi((state) => state.ordering);
|
||||||
@@ -68,19 +89,35 @@ export default function LibraryPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["files", { search, ratings, ordering, perPage, page }],
|
queryKey: [
|
||||||
|
"files",
|
||||||
|
{ search, searchType, ratings, statuses, ordering, perPage, page },
|
||||||
|
],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: String(page),
|
page: String(page),
|
||||||
per_page: String(perPage),
|
per_page: String(perPage),
|
||||||
ordering,
|
ordering,
|
||||||
});
|
});
|
||||||
if (search) params.set("search", search);
|
if (search) {
|
||||||
|
params.set("search", search);
|
||||||
|
params.set("search_type", searchType);
|
||||||
|
}
|
||||||
if (ratings.length) params.set("rating", ratings.join(","));
|
if (ratings.length) params.set("rating", ratings.join(","));
|
||||||
|
if (statuses.length) params.set("status", statuses.join(","));
|
||||||
return api<Paginated<MediaItem>>(`/api/files/?${params.toString()}`);
|
return api<Paginated<MediaItem>>(`/api/files/?${params.toString()}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tagCloudQuery = useQuery({
|
||||||
|
queryKey: ["tag-cloud"],
|
||||||
|
queryFn: () =>
|
||||||
|
api<{ count: number; tags: { tag: string; count: number }[] }>(
|
||||||
|
"/api/tags/cloud/",
|
||||||
|
),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
const pageCount = query.data
|
const pageCount = query.data
|
||||||
? Math.max(1, Math.ceil(query.data.count / perPage))
|
? Math.max(1, Math.ceil(query.data.count / perPage))
|
||||||
: 1;
|
: 1;
|
||||||
@@ -100,10 +137,28 @@ export default function LibraryPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleStatus(value: string) {
|
||||||
|
setPage(1);
|
||||||
|
setStatuses(
|
||||||
|
statuses.includes(value)
|
||||||
|
? statuses.filter((status) => status !== value)
|
||||||
|
: [...statuses, value],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchTag(tag: string) {
|
||||||
|
setSearchInput(tag);
|
||||||
|
setSearch(tag);
|
||||||
|
setSearchType("tags");
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
setSearchInput("");
|
setSearchInput("");
|
||||||
setSearch("");
|
setSearch("");
|
||||||
|
setSearchType("filename");
|
||||||
setRatings([]);
|
setRatings([]);
|
||||||
|
setStatuses([]);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,11 +173,22 @@ export default function LibraryPage() {
|
|||||||
<input
|
<input
|
||||||
id="library-search"
|
id="library-search"
|
||||||
className={cn(inputClass, "pl-8")}
|
className={cn(inputClass, "pl-8")}
|
||||||
placeholder="Filename…"
|
placeholder={
|
||||||
|
searchType === "tags" ? "Tags…" : "Filename…"
|
||||||
|
}
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(event) => setSearchInput(event.target.value)}
|
onChange={(event) => setSearchInput(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={searchType}
|
||||||
|
onChange={(event) => setSearchType(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="filename">Filename</option>
|
||||||
|
<option value="tags">Tags</option>
|
||||||
|
<option value="both">Filename or tags</option>
|
||||||
|
</select>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="submit" className="flex-1">
|
<Button type="submit" className="flex-1">
|
||||||
Search
|
Search
|
||||||
@@ -133,6 +199,29 @@ export default function LibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||||
|
Status
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{statusFilters.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleStatus(option.value)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-2.5 py-1 text-xs font-medium transition",
|
||||||
|
statuses.includes(option.value)
|
||||||
|
? option.active
|
||||||
|
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||||
Rating
|
Rating
|
||||||
@@ -156,6 +245,31 @@ export default function LibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||||
|
Tag cloud
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(tagCloudQuery.data?.tags ?? []).map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.tag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => searchTag(entry.tag)}
|
||||||
|
title={`Search ${entry.tag}`}
|
||||||
|
className="rounded border border-ctp-blue/30 bg-ctp-blue/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-blue transition hover:bg-ctp-blue/20"
|
||||||
|
>
|
||||||
|
{entry.tag} ({entry.count})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{tagCloudQuery.isPending ? (
|
||||||
|
<Spinner className="h-3.5 w-3.5" />
|
||||||
|
) : null}
|
||||||
|
{tagCloudQuery.data && tagCloudQuery.data.tags.length === 0 ? (
|
||||||
|
<p className="text-xs text-ctp-overlay0">No tags yet.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="flex flex-col gap-2">
|
<label className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||||
Sort
|
Sort
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ interface UiState {
|
|||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
ratings: string[];
|
ratings: string[];
|
||||||
setRatings: (ratings: string[]) => void;
|
setRatings: (ratings: string[]) => void;
|
||||||
|
statuses: string[];
|
||||||
|
setStatuses: (statuses: string[]) => void;
|
||||||
perPage: number;
|
perPage: number;
|
||||||
setPerPage: (perPage: number) => void;
|
setPerPage: (perPage: number) => void;
|
||||||
ordering: string;
|
ordering: string;
|
||||||
@@ -23,6 +25,9 @@ export const useUi = create<UiState>()(
|
|||||||
ratings: [],
|
ratings: [],
|
||||||
setRatings: (ratings) => set({ ratings }),
|
setRatings: (ratings) => set({ ratings }),
|
||||||
|
|
||||||
|
statuses: [],
|
||||||
|
setStatuses: (statuses) => set({ statuses }),
|
||||||
|
|
||||||
perPage: 48,
|
perPage: 48,
|
||||||
setPerPage: (perPage) => set({ perPage }),
|
setPerPage: (perPage) => set({ perPage }),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user