Follows: followed tags/pools, feeds, unseen badges and blacklist cloud

Backend (new apps.follows):
- FollowedTag/FollowedPool/FollowedPost models; per-user follows with
  unseen tracking, plus FollowCloud for the cached blacklist cloud.
- Two periodic commands sharing one fetch path: sync_followed_tags and
  sync_followed_pools fetch each followed tag/pool's newest posts (one
  e621 search per unique follow), store unseen feed rows, refresh covers
  and pool metadata; both fall back to anonymous e621 access.
- API: /api/follows/tags|pools (follow, unfollow, mark seen), a merged
  feed with per-follow filtering, and /api/follows/cloud/ which rebuilds
  the blacklisted-tag cloud in a daemon thread when its 10 min cache is
  stale (polling returns building/ready).
- e621 client now supports anonymous reads; trimmed posts carry preview
  URLs for covers and feed tiles.

Frontend:
- /followed page: follow forms, cover cards with unseen badges and
  Mark seen, merged feed with filter/unseen toggle, and a blacklist
  cloud panel that polls while building. Followed nav entry added.
This commit is contained in:
2026-09-17 14:09:30 -05:00
parent c55fa1fdca
commit 03dd235f3a
23 changed files with 1741 additions and 6 deletions
+13 -3
View File
@@ -44,9 +44,17 @@ they land.
using the signed raw file)
- [x] Show candidate posts to link (thumbnail, rating, score, favs, tags;
select then confirm — same flow as uploads, exact MD5 marked)
- [ ] **Follows**
- [ ] Follow tags/pools, Followed screens with unseen badges
- [ ] "Followed" nav entry
- [x] **Follows**
- [x] Follow tags/pools from the `/followed` page (validated against e621,
first feed fetched immediately)
- [x] Followed screens: cover cards (latest image), unseen badges and
Mark seen per follow
- [x] Merged newest-first feed with per-follow filter and unseen-only toggle
- [x] Blacklisted-tag cloud built in a daemon thread, then polled
(10 min TTL; the user's e621 blacklist, guest default as fallback)
- [x] "Followed" nav entry
- [x] Periodic sync commands: `sync_followed_tags` and
`sync_followed_pools` (one e621 search per unique tag/pool)
- [ ] `F` shortcut to favorite a post, tag finder in the Ctrl+K palette
## 3. Uploads & ingestion pipeline
@@ -99,6 +107,8 @@ Files now stage first and are resolved before entering the library.
## 6. Infrastructure
- [ ] Guest blacklist refresh on a timer (`refresh_guest_blacklist` via cron/systemd)
- [ ] Follow sync on a timer (`sync_followed_tags` + `sync_followed_pools` via
cron/systemd, e.g. every 30 minutes)
- [ ] Production setup: build the SPA, serve via Nginx (static + `/media` +
`/library`), systemd unit for Waitress
- [ ] Automated tests (backend API + frontend components)
View File
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class FollowsConfig(AppConfig):
name = "apps.follows"
+97
View File
@@ -0,0 +1,97 @@
"""Blacklisted-tag cloud for the Followed page.
The cloud counts tags from the user's e621 blacklist that appear in their
followed feeds. Building it means one e621 request (the blacklist itself)
plus a local scan of stored feed posts, so it runs in a daemon thread and
the page polls until the fresh result lands.
"""
import logging
import threading
from datetime import timedelta
from fnmatch import fnmatch
from django.contrib.auth import get_user_model
from django.db import connection
from django.utils import timezone
from apps.library import e621, guest_filter
from .models import FollowCloud, FollowedPost
logger = logging.getLogger(__name__)
CLOUD_TTL = timedelta(minutes=10)
BUILD_STALE = timedelta(minutes=5)
MAX_CLOUD_TAGS = 60
def blacklist_patterns_for(user):
"""(patterns, source): the user's e621 blacklist, else the guest default."""
if e621.credentials_configured(user):
try:
me = e621.get(user, "/users/me.json")
lines = str(me.get("blacklisted_tags") or "").splitlines()
patterns = guest_filter.parse_blacklist_patterns(lines)
if patterns:
return patterns, "user"
except e621.E621Error as exc:
logger.warning("Could not fetch the e621 blacklist for %s: %s", user, exc)
return guest_filter.guest_blacklist_patterns(), "default"
def compute_cloud(user):
"""Count blacklist matches across every stored feed post of one user."""
patterns, source = blacklist_patterns_for(user)
counts = {}
seen_posts = set()
rows = FollowedPost.objects.filter(user=user).values_list("post_id", "data")
for post_id, data in rows:
if post_id in seen_posts:
continue
seen_posts.add(post_id)
if not isinstance(data, dict):
continue
categories = data.get("tags")
if not isinstance(categories, dict):
continue
for values in categories.values():
if not isinstance(values, list):
continue
for tag in values:
name = str(tag).strip().lower()
if name and any(fnmatch(name, pattern) for pattern in patterns):
counts[name] = counts.get(name, 0) + 1
tags = sorted(counts.items(), key=lambda entry: (-entry[1], entry[0]))
return {
"tags": [[tag, count] for tag, count in tags[:MAX_CLOUD_TAGS]],
"posts": len(seen_posts),
"source": source,
"blacklist_count": len(patterns),
}
def start_cloud_build(user_id):
thread = threading.Thread(target=run_cloud_build, args=(user_id,), daemon=True)
thread.start()
def run_cloud_build(user_id):
User = get_user_model()
try:
user = User.objects.filter(id=user_id).first()
if user is None:
return
data = compute_cloud(user)
FollowCloud.objects.filter(user_id=user_id).update(
data=data, computed_at=timezone.now()
)
except Exception: # noqa: BLE001 - background builds must not crash workers
logger.exception("Blacklist cloud build failed for user %s", user_id)
finally:
FollowCloud.objects.filter(user_id=user_id).update(
building=False, building_since=None
)
# Background threads hold their own DB connection; release it so
# Gunicorn workers do not leak connections when threads finish.
connection.close()
@@ -0,0 +1,62 @@
from django.core.management.base import BaseCommand, CommandError
from apps.follows import sync
from apps.follows.models import FollowedPool
from apps.library import e621
class Command(BaseCommand):
help = (
"Fetch new posts for every followed pool: fills each feed, refreshes "
"the card cover (latest image), pool name/count and unseen badges."
)
def add_arguments(self, parser):
parser.add_argument(
"--user",
help="Only sync follows belonging to this username "
"(default: every user's follows).",
)
parser.add_argument(
"--limit",
type=int,
default=sync.FEED_LIMIT,
help=f"Posts fetched per pool (default {sync.FEED_LIMIT}).",
)
def handle(self, *args, **options):
username = options.get("user")
if username and sync.preferred_fetch_user(username) is None:
raise CommandError(f"No such user: {username}")
fetch_user = sync.preferred_fetch_user(username)
follows = FollowedPool.objects.select_related("user").order_by(
"pool_id", "user_id"
)
if username:
follows = follows.filter(user__username=username)
follows = list(follows)
grouped = {}
for follow in follows:
grouped.setdefault(follow.pool_id, []).append(follow)
created = 0
for pool_id, group in grouped.items():
try:
posts = sync.search_posts(
fetch_user, f"pool:{pool_id}", options["limit"]
)
except e621.E621Error as exc:
raise CommandError(f"Aborted at pool #{pool_id}: {exc}")
for follow in group:
created += sync.sync_pool_follow(
follow, fetch_user, posts=posts
)
identity = getattr(fetch_user, "username", None) or "anonymous"
self.stdout.write(
self.style.SUCCESS(
f"Synced {len(grouped)} pool(s) across {len(follows)} follow(s) "
f"as {identity}: {created} new post(s)."
)
)
@@ -0,0 +1,60 @@
from django.core.management.base import BaseCommand, CommandError
from apps.follows import sync
from apps.follows.models import FollowedTag
from apps.library import e621
class Command(BaseCommand):
help = (
"Fetch new posts for every followed tag: fills each feed, refreshes "
"the card cover (latest image) and unseen badges."
)
def add_arguments(self, parser):
parser.add_argument(
"--user",
help="Only sync follows belonging to this username "
"(default: every user's follows).",
)
parser.add_argument(
"--limit",
type=int,
default=sync.FEED_LIMIT,
help=f"Posts fetched per tag (default {sync.FEED_LIMIT}).",
)
def handle(self, *args, **options):
username = options.get("user")
if username and sync.preferred_fetch_user(username) is None:
raise CommandError(f"No such user: {username}")
fetch_user = sync.preferred_fetch_user(username)
follows = FollowedTag.objects.select_related("user").order_by(
"tag", "user_id"
)
if username:
follows = follows.filter(user__username=username)
follows = list(follows)
grouped = {}
for follow in follows:
grouped.setdefault(follow.tag, []).append(follow)
created = 0
for tag, group in grouped.items():
try:
posts = sync.search_posts(fetch_user, tag, options["limit"])
except e621.E621Error as exc:
raise CommandError(f"Aborted at tag '{tag}': {exc}")
for follow in group:
created += sync.sync_tag_follow(
follow, fetch_user, posts=posts
)
identity = getattr(fetch_user, "username", None) or "anonymous"
self.stdout.write(
self.style.SUCCESS(
f"Synced {len(grouped)} tag(s) across {len(follows)} follow(s) "
f"as {identity}: {created} new post(s)."
)
)
@@ -0,0 +1,94 @@
# Generated by Django 6.1.1 on 2026-09-17 19:04
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='FollowCloud',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('data', models.JSONField(blank=True, null=True)),
('computed_at', models.DateTimeField(blank=True, null=True)),
('building', models.BooleanField(default=False)),
('building_since', models.DateTimeField(blank=True, null=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='follow_cloud', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='FollowedPool',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('pool_id', models.IntegerField()),
('name', models.CharField(blank=True, default='', max_length=200)),
('post_count', models.IntegerField(default=0)),
('cover_data', models.JSONField(blank=True, null=True)),
('last_synced_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='followed_pools', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['pool_id'],
},
),
migrations.CreateModel(
name='FollowedTag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tag', models.CharField(max_length=200)),
('cover_data', models.JSONField(blank=True, null=True)),
('last_synced_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='followed_tags', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['tag'],
},
),
migrations.CreateModel(
name='FollowedPost',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('post_id', models.IntegerField(db_index=True)),
('data', models.JSONField(default=dict)),
('seen', models.BooleanField(db_index=True, default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('pool', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to='follows.followedpool')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='followed_posts', to=settings.AUTH_USER_MODEL)),
('tag', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to='follows.followedtag')),
],
options={
'ordering': ['-post_id'],
},
),
migrations.AddConstraint(
model_name='followedpool',
constraint=models.UniqueConstraint(fields=('user', 'pool_id'), name='uniq_followed_pool_per_user'),
),
migrations.AddConstraint(
model_name='followedtag',
constraint=models.UniqueConstraint(fields=('user', 'tag'), name='uniq_followed_tag_per_user'),
),
migrations.AddIndex(
model_name='followedpost',
index=models.Index(fields=['user', 'post_id'], name='follows_fol_user_id_47fdab_idx'),
),
migrations.AddConstraint(
model_name='followedpost',
constraint=models.UniqueConstraint(fields=('tag', 'post_id'), name='uniq_followed_tag_post'),
),
migrations.AddConstraint(
model_name='followedpost',
constraint=models.UniqueConstraint(fields=('pool', 'post_id'), name='uniq_followed_pool_post'),
),
]
+114
View File
@@ -0,0 +1,114 @@
from django.conf import settings
from django.db import models
class FollowedTag(models.Model):
"""A tag one user subscribes to; feeds are filled by the sync command."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="followed_tags",
)
tag = models.CharField(max_length=200)
cover_data = models.JSONField(null=True, blank=True)
last_synced_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["tag"]
constraints = [
models.UniqueConstraint(
fields=["user", "tag"], name="uniq_followed_tag_per_user"
),
]
def __str__(self):
return f"{self.user} follows tag:{self.tag}"
class FollowedPool(models.Model):
"""A pool one user subscribes to; feeds are filled by the sync command."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="followed_pools",
)
pool_id = models.IntegerField()
name = models.CharField(max_length=200, blank=True, default="")
post_count = models.IntegerField(default=0)
cover_data = models.JSONField(null=True, blank=True)
last_synced_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["pool_id"]
constraints = [
models.UniqueConstraint(
fields=["user", "pool_id"], name="uniq_followed_pool_per_user"
),
]
def __str__(self):
return f"{self.user} follows pool:{self.pool_id}"
class FollowedPost(models.Model):
"""One post seen in a followed feed (stored once per follow source)."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="followed_posts",
)
tag = models.ForeignKey(
FollowedTag,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="posts",
)
pool = models.ForeignKey(
FollowedPool,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="posts",
)
post_id = models.IntegerField(db_index=True)
data = models.JSONField(default=dict)
seen = models.BooleanField(default=False, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-post_id"]
indexes = [models.Index(fields=["user", "post_id"])]
constraints = [
models.UniqueConstraint(
fields=["tag", "post_id"], name="uniq_followed_tag_post"
),
models.UniqueConstraint(
fields=["pool", "post_id"], name="uniq_followed_pool_post"
),
]
def __str__(self):
return f"#{self.post_id} for {self.user}"
class FollowCloud(models.Model):
"""Cached blacklisted-tag cloud for the Followed page, built in a thread."""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="follow_cloud",
)
data = models.JSONField(null=True, blank=True)
computed_at = models.DateTimeField(null=True, blank=True)
building = models.BooleanField(default=False)
building_since = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f"Follow cloud for {self.user}"
+53
View File
@@ -0,0 +1,53 @@
from rest_framework import serializers
from .models import FollowedPool, FollowedTag
class FollowedTagSerializer(serializers.ModelSerializer):
unseen_count = serializers.IntegerField(read_only=True)
class Meta:
model = FollowedTag
fields = [
"id",
"tag",
"unseen_count",
"cover_data",
"last_synced_at",
"created_at",
]
read_only_fields = [
"id",
"tag",
"unseen_count",
"cover_data",
"last_synced_at",
"created_at",
]
class FollowedPoolSerializer(serializers.ModelSerializer):
unseen_count = serializers.IntegerField(read_only=True)
class Meta:
model = FollowedPool
fields = [
"id",
"pool_id",
"name",
"post_count",
"unseen_count",
"cover_data",
"last_synced_at",
"created_at",
]
read_only_fields = [
"id",
"pool_id",
"name",
"post_count",
"unseen_count",
"cover_data",
"last_synced_at",
"created_at",
]
+171
View File
@@ -0,0 +1,171 @@
"""Fetch and store followed tag/pool feeds from e621.
Used by the two periodic management commands (`sync_followed_tags`,
`sync_followed_pools`) and by the follow API for the first fill of a new
subscription. One e621 search per unique tag/pool is shared across every
user following it.
"""
import logging
from django.contrib.auth import get_user_model
from django.utils import timezone
from apps.library import e621
from apps.library.services import trim_e621_post
from .models import FollowedPool, FollowedPost, FollowedTag
logger = logging.getLogger(__name__)
FEED_LIMIT = 24
PRUNE_KEEP = 200
def preferred_fetch_user(username=None):
"""A configured account for sync calls; None means run anonymously.
e621 serves post searches and pool reads anonymously, so a missing
account is not fatal — credentials mainly raise rate limits.
"""
User = get_user_model()
if username:
return User.objects.filter(username=username).first()
return (
User.objects.filter(is_staff=True)
.exclude(e621_username="")
.exclude(e621_api_key="")
.order_by("id")
.first()
)
def search_posts(user, term, limit=FEED_LIMIT):
"""Newest posts for a search term (``tag`` or ``pool:<id>``)."""
payload = e621.get(
user,
"/posts.json",
params={"tags": term, "limit": limit},
require_auth=False,
)
posts = payload.get("posts") if isinstance(payload, dict) else None
return posts or []
def cover_payload(post):
"""Lightweight card cover: enough to render a thumbnail and rating."""
if not isinstance(post, dict):
return None
preview = post.get("preview") or {}
score = post.get("score") or {}
return {
"post_id": post.get("id"),
"rating": post.get("rating"),
"preview_url": preview.get("url"),
"score": score.get("total"),
"fav_count": post.get("fav_count"),
}
def feed_payload(post):
"""Compact stored post for the feed (tags included for the tag cloud)."""
trimmed = trim_e621_post(post)
if trimmed is None:
return None
return {
"id": trimmed.get("id"),
"created_at": trimmed.get("created_at"),
"rating": trimmed.get("rating"),
"tags": trimmed.get("tags") or {},
"score": trimmed.get("score") or {},
"fav_count": trimmed.get("fav_count"),
"file": trimmed.get("file") or {},
"preview": trimmed.get("preview") or {},
"uploader_name": trimmed.get("uploader_name"),
}
def _store_posts(follow, posts, tag=None, pool=None):
"""Insert unseen rows for new posts. Returns how many were added."""
existing = set(follow.posts.values_list("post_id", flat=True))
rows = []
for post in posts:
post_id = post.get("id") if isinstance(post, dict) else None
if not isinstance(post_id, int) or post_id in existing:
continue
data = feed_payload(post)
if data is None:
continue
rows.append(
FollowedPost(
user=follow.user,
post_id=post_id,
data=data,
tag=tag,
pool=pool,
)
)
if rows:
FollowedPost.objects.bulk_create(rows, ignore_conflicts=True)
_prune(follow)
return len(rows)
def _prune(follow):
"""Keep only the newest PRUNE_KEEP posts per follow."""
keep = list(
follow.posts.order_by("-post_id").values_list("id", flat=True)[
:PRUNE_KEEP
]
)
follow.posts.exclude(id__in=keep).delete()
def sync_tag_follow(follow: FollowedTag, fetch_user=None, limit=FEED_LIMIT, posts=None):
"""Fetch new posts for one followed tag; updates cover and sync time."""
if posts is None:
posts = search_posts(fetch_user, follow.tag, limit)
added = _store_posts(follow, posts, tag=follow)
updates = ["last_synced_at"]
cover = cover_payload(posts[0]) if posts else None
if cover is not None:
follow.cover_data = cover
updates.append("cover_data")
follow.last_synced_at = timezone.now()
follow.save(update_fields=updates)
return added
def sync_pool_follow(
follow: FollowedPool,
fetch_user=None,
limit=FEED_LIMIT,
posts=None,
refresh_meta=True,
):
"""Fetch new posts for one followed pool; updates cover and metadata."""
if posts is None:
posts = search_posts(fetch_user, f"pool:{follow.pool_id}", limit)
added = _store_posts(follow, posts, pool=follow)
updates = ["last_synced_at"]
cover = cover_payload(posts[0]) if posts else None
if cover is not None:
follow.cover_data = cover
updates.append("cover_data")
if refresh_meta or not follow.name:
try:
pool = e621.get(
fetch_user,
f"/pools/{follow.pool_id}.json",
require_auth=False,
)
except e621.E621Error as exc:
logger.warning("Could not refresh pool %s: %s", follow.pool_id, exc)
else:
if isinstance(pool, dict) and pool.get("id"):
follow.name = str(pool.get("name") or "")[:200]
follow.post_count = int(pool.get("post_count") or 0)
updates += ["name", "post_count"]
follow.last_synced_at = timezone.now()
follow.save(update_fields=updates)
return added
+25
View File
@@ -0,0 +1,25 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import (
FollowCloudView,
FollowedFeedView,
FollowedPoolViewSet,
FollowedPostSeenView,
FollowedTagViewSet,
)
router = DefaultRouter()
router.register("follows/tags", FollowedTagViewSet, basename="followed-tag")
router.register("follows/pools", FollowedPoolViewSet, basename="followed-pool")
urlpatterns = [
path("", include(router.urls)),
path("follows/feed/", FollowedFeedView.as_view(), name="followed_feed"),
path("follows/cloud/", FollowCloudView.as_view(), name="followed_cloud"),
path(
"follows/posts/<int:post_id>/seen/",
FollowedPostSeenView.as_view(),
name="followed_post_seen",
),
]
+317
View File
@@ -0,0 +1,317 @@
import re
from django.db.models import Count, Q
from django.utils import timezone
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.library import e621
from . import cloud as cloud_service
from . import sync
from .models import FollowCloud, FollowedPool, FollowedPost, FollowedTag
from .serializers import FollowedPoolSerializer, FollowedTagSerializer
POOL_URL_RE = re.compile(r"/pools/(\d+)")
def _parse_page(request, default_per_page=48):
try:
per_page = int(request.query_params.get("per_page", default_per_page))
except ValueError:
per_page = default_per_page
try:
page = int(request.query_params.get("page", 1))
except ValueError:
page = 1
return max(1, page), min(200, max(1, per_page))
class FollowedTagViewSet(
mixins.ListModelMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""Tags the current user follows."""
serializer_class = FollowedTagSerializer
permission_classes = [IsAuthenticated]
http_method_names = ["get", "post", "delete", "head", "options"]
def get_queryset(self):
return (
FollowedTag.objects.filter(user=self.request.user)
.annotate(
unseen_count=Count("posts", filter=Q(posts__seen=False), distinct=True)
)
.order_by("tag")
)
def create(self, request):
tag = str(request.data.get("tag") or "").strip().lower()[:200]
if not tag:
return Response(
{"detail": "A tag is required."}, status=status.HTTP_400_BAD_REQUEST
)
if FollowedTag.objects.filter(user=request.user, tag=tag).exists():
return Response(
{"detail": f"Already following '{tag}'."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
results = e621.get(
request.user,
"/tags.json",
params={"search[name_matches]": tag, "limit": 5},
require_auth=False,
)
except e621.E621Error as exc:
return Response(
{"detail": str(exc)}, status=status.HTTP_502_BAD_GATEWAY
)
names = {
str(entry.get("name", "")).lower()
for entry in results
if isinstance(entry, dict)
}
if tag not in names:
return Response(
{"detail": f"e621 has no tag named '{tag}'."},
status=status.HTTP_400_BAD_REQUEST,
)
follow = FollowedTag.objects.create(user=request.user, tag=tag)
FollowCloud.objects.filter(user=request.user).update(computed_at=None)
try:
sync.sync_tag_follow(follow, request.user)
except e621.E621Error:
# The periodic command will catch up; the follow still stands.
pass
follow = self.get_queryset().get(pk=follow.pk)
return Response(
self.get_serializer(follow).data, status=status.HTTP_201_CREATED
)
def perform_destroy(self, instance):
instance.delete()
FollowCloud.objects.filter(user=self.request.user).update(computed_at=None)
@action(detail=True, methods=["post"])
def seen(self, request, pk=None):
follow = self.get_object()
follow.posts.update(seen=True)
return Response({"success": True})
class FollowedPoolViewSet(
mixins.ListModelMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""Pools the current user follows."""
serializer_class = FollowedPoolSerializer
permission_classes = [IsAuthenticated]
http_method_names = ["get", "post", "delete", "head", "options"]
def get_queryset(self):
return (
FollowedPool.objects.filter(user=self.request.user)
.annotate(
unseen_count=Count("posts", filter=Q(posts__seen=False), distinct=True)
)
.order_by("pool_id")
)
def create(self, request):
raw = str(
request.data.get("pool") or request.data.get("pool_id") or ""
).strip()
match = POOL_URL_RE.search(raw)
if match:
pool_id = int(match.group(1))
elif raw.isdigit():
pool_id = int(raw)
else:
return Response(
{"detail": "Enter an e621 pool ID or pool URL."},
status=status.HTTP_400_BAD_REQUEST,
)
if FollowedPool.objects.filter(user=request.user, pool_id=pool_id).exists():
return Response(
{"detail": f"Already following pool #{pool_id}."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
pool = e621.get(
request.user, f"/pools/{pool_id}.json", require_auth=False
)
except e621.E621NotFound:
return Response(
{"detail": f"e621 has no pool #{pool_id}."},
status=status.HTTP_400_BAD_REQUEST,
)
except e621.E621Error as exc:
return Response(
{"detail": str(exc)}, status=status.HTTP_502_BAD_GATEWAY
)
follow = FollowedPool.objects.create(
user=request.user,
pool_id=pool_id,
name=str(pool.get("name") or "")[:200],
post_count=int(pool.get("post_count") or 0),
)
FollowCloud.objects.filter(user=request.user).update(computed_at=None)
try:
sync.sync_pool_follow(follow, request.user, refresh_meta=False)
except e621.E621Error:
pass
follow = self.get_queryset().get(pk=follow.pk)
return Response(
self.get_serializer(follow).data, status=status.HTTP_201_CREATED
)
def perform_destroy(self, instance):
instance.delete()
FollowCloud.objects.filter(user=self.request.user).update(computed_at=None)
@action(detail=True, methods=["post"])
def seen(self, request, pk=None):
follow = self.get_object()
follow.posts.update(seen=True)
return Response({"success": True})
class FollowedFeedView(APIView):
"""Merged newest-first feed across followed tags and pools."""
permission_classes = [IsAuthenticated]
def get(self, request):
page, per_page = _parse_page(request)
queryset = FollowedPost.objects.filter(user=request.user).order_by(
"-post_id"
)
tag_id = request.query_params.get("tag", "")
pool_id = request.query_params.get("pool", "")
if tag_id.isdigit():
queryset = queryset.filter(tag_id=int(tag_id))
if pool_id.isdigit():
queryset = queryset.filter(pool_id=int(pool_id))
if request.query_params.get("unseen") == "1":
queryset = queryset.filter(seen=False)
tag_names = dict(
FollowedTag.objects.filter(user=request.user).values_list("id", "tag")
)
pool_names = dict(
FollowedPool.objects.filter(user=request.user).values_list(
"id", "name"
)
)
entries = {}
for post_id, seen, data, tag, pool in queryset.values_list(
"post_id", "seen", "data", "tag_id", "pool_id"
)[:2000]:
entry = entries.get(post_id)
if entry is None:
entry = entries[post_id] = {
"post_id": post_id,
"seen": seen,
"data": data,
"sources": [],
}
else:
entry["seen"] = entry["seen"] and seen
if tag and not any(
source["kind"] == "tag" and source["id"] == tag
for source in entry["sources"]
):
entry["sources"].append(
{
"kind": "tag",
"id": tag,
"label": tag_names.get(tag, f"tag:{tag}"),
}
)
if pool and not any(
source["kind"] == "pool" and source["id"] == pool
for source in entry["sources"]
):
entry["sources"].append(
{
"kind": "pool",
"id": pool,
"label": pool_names.get(pool) or f"Pool #{pool}",
}
)
items = list(entries.values())
count = len(items)
start = (page - 1) * per_page
return Response(
{
"count": count,
"page": page,
"per_page": per_page,
"has_next": start + per_page < count,
"results": items[start : start + per_page],
}
)
class FollowedPostSeenView(APIView):
"""Mark one post seen across every follow it appears in."""
permission_classes = [IsAuthenticated]
def post(self, request, post_id):
FollowedPost.objects.filter(user=request.user, post_id=post_id).update(
seen=True
)
return Response({"success": True})
class FollowCloudView(APIView):
"""The blacklisted-tag cloud, rebuilt in a thread when stale."""
permission_classes = [IsAuthenticated]
def get(self, request):
cloud, _ = FollowCloud.objects.get_or_create(user=request.user)
now = timezone.now()
stale = cloud.computed_at is None or now - cloud.computed_at > (
cloud_service.CLOUD_TTL
)
if stale:
reclaim = Q(building=False) | Q(
building_since__lt=now - cloud_service.BUILD_STALE
)
started = (
FollowCloud.objects.filter(pk=cloud.pk)
.filter(reclaim)
.update(building=True, building_since=now)
)
if started:
cloud_service.start_cloud_build(request.user.id)
cloud.refresh_from_db()
building = cloud.building and not (
cloud.building_since
and now - cloud.building_since > cloud_service.BUILD_STALE
)
data = cloud.data or {}
return Response(
{
"status": "building" if building else "ready",
"stale": stale,
"tags": data.get("tags", []),
"posts": data.get("posts", 0),
"source": data.get("source"),
"blacklist_count": data.get("blacklist_count", 0),
"computed_at": cloud.computed_at,
}
)
+7 -3
View File
@@ -41,13 +41,17 @@ def _wait_for_slot():
_last_request_at = time.monotonic()
def get(user, path, params=None, timeout=30):
def get(user, path, params=None, timeout=30, require_auth=True):
"""GET an e621 API path using the user's credentials.
Reads that e621 serves anonymously (searches, pools, tags) can pass
require_auth=False; matching endpoints keep requiring credentials.
Raises E621NotFound for 404s and E621Error for everything else that isn't
a 2xx, so callers never see requests exceptions.
"""
if not credentials_configured(user):
configured = credentials_configured(user)
if require_auth and not configured:
raise E621Error("Configure your e621 credentials in Account first.")
base = (getattr(user, "e621_base_url", "") or "https://e621.net").rstrip("/")
_wait_for_slot()
@@ -55,7 +59,7 @@ def get(user, path, params=None, timeout=30):
response = requests.get(
f"{base}{path}",
params=params,
auth=(user.e621_username, user.e621_api_key),
auth=(user.e621_username, user.e621_api_key) if configured else None,
headers={"User-Agent": settings.USER_AGENT},
timeout=timeout,
)
+3
View File
@@ -323,6 +323,9 @@ def trim_e621_post(post):
"height": file_data.get("height"),
"url": file_data.get("url"),
},
"preview": {
"url": (post.get("preview") or {}).get("url"),
},
"uploader_name": post.get("uploader_name"),
}
+1
View File
@@ -52,6 +52,7 @@ INSTALLED_APPS = [
"django_filters",
"apps.accounts",
"apps.library",
"apps.follows",
"apps.core",
]
+1
View File
@@ -9,6 +9,7 @@ urlpatterns = [
path("api/", include("apps.accounts.api_urls")),
path("api/", include("apps.core.urls")),
path("api/", include("apps.library.urls")),
path("api/", include("apps.follows.urls")),
]
if settings.DEBUG:
+9
View File
@@ -16,6 +16,7 @@ import RegisterPage from "@/features/auth/RegisterPage";
import DetailPage from "@/features/detail/DetailPage";
import DeletePage from "@/features/library/DeletePage";
import DuplicatesPage from "@/features/library/DuplicatesPage";
import FollowedPage from "@/features/follows/FollowedPage";
import LibraryPage from "@/features/library/LibraryPage";
import Md5Redirect from "@/features/library/Md5Redirect";
import OnlinePage from "@/features/online/OnlinePage";
@@ -70,6 +71,14 @@ export default function App() {
<Route path="/view/:md5" element={<Md5Redirect />} />
<Route path="/online" element={<OnlinePage />} />
<Route path="/online/view/:postId" element={<PostRedirect />} />
<Route
path="/followed"
element={
<RequireAuth>
<FollowedPage />
</RequireAuth>
}
/>
<Route
path="/duplicates"
element={
+1
View File
@@ -60,6 +60,7 @@ export function AppShell() {
const navItems = [
{ to: "/", label: "Library" },
{ to: "/online", label: "Online" },
...(user ? [{ to: "/followed", label: "Followed" }] : []),
...(user?.can_upload
? [
{ to: "/upload", label: "Upload" },
@@ -0,0 +1,637 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, Plus, Star, StarOff } from "lucide-react";
import { useState, type FormEvent } from "react";
import { Link } from "react-router-dom";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatDate } from "@/lib/format";
import type {
FollowCloud,
FollowCoverData,
FollowedPool,
FollowedTag,
FollowFeed,
Paginated,
} from "@/lib/types";
const ratingPill: Record<string, string> = {
s: "bg-ctp-green text-ctp-crust",
q: "bg-ctp-peach text-ctp-crust",
e: "bg-ctp-red text-ctp-crust",
};
function invalidateFollows(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: ["follows-tags"] });
void queryClient.invalidateQueries({ queryKey: ["follows-pools"] });
void queryClient.invalidateQueries({ queryKey: ["follows-feed"] });
}
function unseenLabel(count: number): string {
return count > 99 ? "99+ unseen" : `${count} unseen`;
}
function FollowForms() {
const queryClient = useQueryClient();
const [tag, setTag] = useState("");
const [pool, setPool] = useState("");
const [error, setError] = useState<string | null>(null);
const tagMutation = useMutation({
mutationFn: (value: string) =>
api<FollowedTag>("/api/follows/tags/", {
method: "POST",
json: { tag: value },
}),
onSuccess: () => {
setTag("");
setError(null);
invalidateFollows(queryClient);
},
onError: (err) => setError(errorMessage(err)),
});
const poolMutation = useMutation({
mutationFn: (value: string) =>
api<FollowedPool>("/api/follows/pools/", {
method: "POST",
json: { pool: value },
}),
onSuccess: () => {
setPool("");
setError(null);
invalidateFollows(queryClient);
},
onError: (err) => setError(errorMessage(err)),
});
function submitTag(event: FormEvent) {
event.preventDefault();
const value = tag.trim().toLowerCase();
if (value) tagMutation.mutate(value);
}
function submitPool(event: FormEvent) {
event.preventDefault();
const value = pool.trim();
if (value) poolMutation.mutate(value);
}
return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<form
onSubmit={submitTag}
className="flex flex-wrap items-center gap-2"
>
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Follow a tag
</span>
<input
className={cn(inputClass, "w-48 font-mono")}
placeholder="e.g. canine"
value={tag}
onChange={(event) => setTag(event.target.value)}
/>
<Button type="submit" disabled={tagMutation.isPending || !tag.trim()}>
{tagMutation.isPending ? <Spinner className="h-3.5 w-3.5" /> : <Star className="h-4 w-4" />}
Follow
</Button>
</form>
<form
onSubmit={submitPool}
className="mt-3 flex flex-wrap items-center gap-2"
>
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Follow a pool
</span>
<input
className={cn(inputClass, "w-48 font-mono")}
placeholder="Pool ID or URL"
value={pool}
onChange={(event) => setPool(event.target.value)}
/>
<Button
type="submit"
variant="secondary"
disabled={poolMutation.isPending || !pool.trim()}
>
{poolMutation.isPending ? <Spinner className="h-3.5 w-3.5" /> : <Plus className="h-4 w-4" />}
Follow
</Button>
</form>
{error ? <p className="mt-2 text-xs text-ctp-red">{error}</p> : null}
</section>
);
}
function FollowCard({
title,
meta,
cover,
unseen,
syncedAt,
selected,
onSelect,
onSeen,
onUnfollow,
}: {
title: string;
meta: string;
cover: FollowCoverData | null;
unseen: number;
syncedAt: string | null;
selected: boolean;
onSelect: () => void;
onSeen: () => void;
onUnfollow: () => void;
}) {
const metaLine = [meta, syncedAt ? `synced ${formatDate(syncedAt)}` : null]
.filter(Boolean)
.join(" · ");
return (
<div
className={cn(
"overflow-hidden rounded-lg border bg-ctp-base transition",
selected
? "border-ctp-mauve ring-2 ring-ctp-mauve/30"
: "border-ctp-surface0 hover:border-ctp-surface2",
)}
>
<button
type="button"
onClick={onSelect}
className="relative block aspect-[4/3] w-full overflow-hidden bg-ctp-mantle"
title={selected ? "Clear the feed filter" : "Show only this in the feed"}
>
{cover?.preview_url ? (
<img
src={cover.preview_url}
alt={title}
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-xs text-ctp-overlay0">
No cover yet — run a sync
</span>
)}
{unseen > 0 ? (
<span className="absolute right-2 top-2 rounded-full bg-ctp-red px-2 py-0.5 font-mono text-[10px] font-bold text-ctp-crust">
{unseenLabel(unseen)}
</span>
) : null}
{cover?.rating ? (
<span
className={cn(
"absolute left-2 top-2 rounded-full px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase",
ratingPill[cover.rating] ?? "bg-ctp-surface1 text-ctp-text",
)}
>
{cover.rating}
</span>
) : null}
</button>
<div className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-mono text-sm text-ctp-text" title={title}>
{title}
</p>
{metaLine ? (
<p className="truncate text-[11px] text-ctp-overlay0">
{metaLine}
</p>
) : null}
</div>
<button
type="button"
onClick={onUnfollow}
title="Unfollow"
className="shrink-0 rounded p-1 text-ctp-overlay0 transition hover:bg-ctp-red/15 hover:text-ctp-red"
>
<StarOff className="h-4 w-4" />
</button>
</div>
<div className="mt-2 flex items-center gap-2">
<Button
variant="secondary"
className="px-2 py-1 text-xs"
disabled={unseen === 0}
onClick={onSeen}
>
<Eye className="h-3.5 w-3.5" /> Mark seen
</Button>
</div>
</div>
</div>
);
}
function BlacklistCloudPanel() {
const query = useQuery({
queryKey: ["follows-cloud"],
queryFn: () => api<FollowCloud>("/api/follows/cloud/"),
refetchInterval: (q) =>
q.state.data?.status === "building" ? 2000 : false,
});
const cloud = query.data;
return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Blacklisted tags
</h2>
{cloud?.status === "building" ? (
<span className="flex items-center gap-1.5 text-[11px] text-ctp-overlay0">
<Spinner className="h-3 w-3" /> building…
</span>
) : null}
</div>
<p className="mt-1 text-xs text-ctp-overlay0">
{cloud?.source === "user"
? "From your e621 blacklist"
: "From e621's anonymous default blacklist"}
{cloud ? ` · ${cloud.blacklist_count} entries` : ""}
{cloud ? ` · ${cloud.posts} feed post(s) scanned` : ""}
</p>
{query.isPending ? (
<div className="mt-3 flex justify-center py-4">
<Spinner className="h-4 w-4" />
</div>
) : cloud && cloud.tags.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{cloud.tags.map(([tag, count]) => (
<span
key={tag}
className="rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
>
{tag} ({count})
</span>
))}
</div>
) : (
<p className="mt-3 text-xs text-ctp-overlay0">
No blacklisted tags in your feeds.
</p>
)}
{cloud?.computed_at ? (
<p className="mt-3 font-mono text-[10px] text-ctp-overlay0">
computed {formatDate(cloud.computed_at)}
</p>
) : null}
</section>
);
}
export default function FollowedPage() {
const queryClient = useQueryClient();
const [feedSource, setFeedSource] = useState<{
kind: "all" | "tag" | "pool";
id?: number;
label?: string;
}>({ kind: "all" });
const [unseenOnly, setUnseenOnly] = useState(false);
const [page, setPage] = useState(1);
const tagsQuery = useQuery({
queryKey: ["follows-tags"],
queryFn: () => api<Paginated<FollowedTag>>("/api/follows/tags/"),
});
const poolsQuery = useQuery({
queryKey: ["follows-pools"],
queryFn: () => api<Paginated<FollowedPool>>("/api/follows/pools/"),
});
const feedQuery = useQuery({
queryKey: [
"follows-feed",
feedSource.kind,
feedSource.id ?? null,
unseenOnly,
page,
],
queryFn: () => {
const params = new URLSearchParams({
page: String(page),
per_page: "48",
});
if (feedSource.kind === "tag") params.set("tag", String(feedSource.id));
if (feedSource.kind === "pool") params.set("pool", String(feedSource.id));
if (unseenOnly) params.set("unseen", "1");
return api<FollowFeed>(`/api/follows/feed/?${params.toString()}`);
},
});
const seenFollow = useMutation({
mutationFn: ({ kind, id }: { kind: "tag" | "pool"; id: number }) =>
api(`/api/follows/${kind === "tag" ? "tags" : "pools"}/${id}/seen/`, {
method: "POST",
}),
onSuccess: () => invalidateFollows(queryClient),
});
const seenPost = useMutation({
mutationFn: (postId: number) =>
api(`/api/follows/posts/${postId}/seen/`, { method: "POST" }),
onSuccess: () => invalidateFollows(queryClient),
});
const unfollow = useMutation({
mutationFn: ({ kind, id }: { kind: "tag" | "pool"; id: number }) =>
api(`/api/follows/${kind === "tag" ? "tags" : "pools"}/${id}/`, {
method: "DELETE",
}),
onSuccess: () => invalidateFollows(queryClient),
});
function selectSource(source: {
kind: "all" | "tag" | "pool";
id?: number;
label?: string;
}) {
setPage(1);
setFeedSource((current) =>
current.kind === source.kind && current.id === source.id
? { kind: "all" }
: source,
);
}
const tags = tagsQuery.data?.results ?? [];
const pools = poolsQuery.data?.results ?? [];
const feed = feedQuery.data;
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<header>
<h1 className="text-lg font-semibold">Followed</h1>
<p className="mt-1 text-sm text-ctp-overlay0">
New posts from your followed e621 tags and pools. The periodic
commands refresh these feeds and their covers.
</p>
</header>
<FollowForms />
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_300px]">
<div className="flex min-w-0 flex-col gap-6">
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Tags{" "}
<span className="font-mono text-[11px] text-ctp-overlay0">
{tags.length}
</span>
</h2>
{tagsQuery.isPending ? (
<div className="flex justify-center py-8">
<Spinner className="h-5 w-5" />
</div>
) : tags.length === 0 ? (
<p className="text-xs text-ctp-overlay0">
No followed tags yet — follow one above.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{tags.map((follow) => (
<FollowCard
key={follow.id}
title={follow.tag}
meta={
follow.cover_data?.post_id
? `latest #${follow.cover_data.post_id}`
: ""
}
cover={follow.cover_data}
unseen={follow.unseen_count}
syncedAt={follow.last_synced_at}
selected={
feedSource.kind === "tag" && feedSource.id === follow.id
}
onSelect={() =>
selectSource({
kind: "tag",
id: follow.id,
label: `tag:${follow.tag}`,
})
}
onSeen={() =>
seenFollow.mutate({ kind: "tag", id: follow.id })
}
onUnfollow={() =>
unfollow.mutate({ kind: "tag", id: follow.id })
}
/>
))}
</div>
)}
</section>
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Pools{" "}
<span className="font-mono text-[11px] text-ctp-overlay0">
{pools.length}
</span>
</h2>
{poolsQuery.isPending ? (
<div className="flex justify-center py-8">
<Spinner className="h-5 w-5" />
</div>
) : pools.length === 0 ? (
<p className="text-xs text-ctp-overlay0">
No followed pools yet — paste a pool ID or URL above.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{pools.map((follow) => (
<FollowCard
key={follow.id}
title={follow.name || `Pool #${follow.pool_id}`}
meta={`#${follow.pool_id}${
follow.cover_data?.post_id
? ` · latest #${follow.cover_data.post_id}`
: ""
}`}
cover={follow.cover_data}
unseen={follow.unseen_count}
syncedAt={follow.last_synced_at}
selected={
feedSource.kind === "pool" && feedSource.id === follow.id
}
onSelect={() =>
selectSource({
kind: "pool",
id: follow.id,
label: follow.name || `Pool #${follow.pool_id}`,
})
}
onSeen={() =>
seenFollow.mutate({ kind: "pool", id: follow.id })
}
onUnfollow={() =>
unfollow.mutate({ kind: "pool", id: follow.id })
}
/>
))}
</div>
)}
</section>
<section className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Feed{" "}
<span className="font-mono text-[11px] text-ctp-overlay0">
{feed ? `${feed.count}` : "…"}
{feedSource.kind !== "all"
? ` · showing ${feedSource.label}`
: ""}
</span>
</h2>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setPage(1);
setUnseenOnly((value) => !value);
}}
className={cn(
"rounded-full border px-2.5 py-1 text-xs font-medium transition",
unseenOnly
? "border-ctp-blue/40 bg-ctp-blue/15 text-ctp-blue"
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
)}
>
Unseen only
</button>
{feedSource.kind !== "all" ? (
<button
type="button"
onClick={() => {
setPage(1);
setFeedSource({ kind: "all" });
}}
className="text-xs text-ctp-blue hover:underline"
>
Clear filter
</button>
) : null}
</div>
</div>
{feedQuery.isPending ? (
<div className="flex justify-center py-12">
<Spinner className="h-5 w-5" />
</div>
) : !feed || feed.results.length === 0 ? (
<EmptyState
title="Nothing in the feed"
description={
unseenOnly
? "Everything here is marked seen."
: "Follow tags or pools, or run a sync command to fetch posts."
}
/>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
{feed.results.map((entry) => (
<Link
key={entry.post_id}
to={`/detail/${entry.post_id}`}
onClick={() => {
if (!entry.seen) seenPost.mutate(entry.post_id);
}}
className="group overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2"
>
<span className="relative block aspect-square overflow-hidden bg-ctp-mantle">
{entry.data.preview?.url ? (
<img
src={entry.data.preview.url}
alt={`Post #${entry.post_id}`}
loading="lazy"
className="h-full w-full object-cover transition group-hover:scale-[1.03]"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] text-ctp-overlay0">
No preview
</span>
)}
{!entry.seen ? (
<span
className="absolute left-2 top-2 h-2.5 w-2.5 rounded-full bg-ctp-blue ring-2 ring-ctp-crust"
title="Unseen"
/>
) : null}
{entry.data.rating ? (
<span
className={cn(
"absolute right-2 top-2 rounded-full px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase",
ratingPill[entry.data.rating] ??
"bg-ctp-surface1 text-ctp-text",
)}
>
{entry.data.rating}
</span>
) : null}
</span>
<span className="block px-2 py-1.5">
<span className="block font-mono text-[11px] text-ctp-subtext0">
#{entry.post_id}
<span className="text-ctp-overlay0">
{" "}
▲ {entry.data.score?.total ?? 0}
{entry.data.fav_count
? ` · ${entry.data.fav_count} ★`
: ""}
</span>
</span>
<span className="block truncate text-[10px] text-ctp-overlay0">
{entry.sources.map((source) => source.label).join(", ")}
</span>
</span>
</Link>
))}
</div>
)}
{feed && (page > 1 || feed.has_next) ? (
<div className="flex items-center justify-center gap-3">
<Button
variant="secondary"
disabled={page <= 1 || feedQuery.isFetching}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Previous
</Button>
<span className="font-mono text-xs text-ctp-subtext0">
Page {page}
</span>
<Button
variant="secondary"
disabled={!feed.has_next || feedQuery.isFetching}
onClick={() => setPage((current) => current + 1)}
>
Next
</Button>
</div>
) : null}
</section>
</div>
<aside className="flex flex-col gap-4">
<BlacklistCloudPanel />
</aside>
</div>
</div>
);
}
+71
View File
@@ -204,6 +204,77 @@ export interface MatchTask {
updated_at: string;
}
export interface FollowCoverData {
post_id: number | null;
rating: string | null;
preview_url: string | null;
score: number | null;
fav_count: number | null;
}
export interface FollowedTag {
id: number;
tag: string;
unseen_count: number;
cover_data: FollowCoverData | null;
last_synced_at: string | null;
created_at: string;
}
export interface FollowedPool {
id: number;
pool_id: number;
name: string;
post_count: number;
unseen_count: number;
cover_data: FollowCoverData | null;
last_synced_at: string | null;
created_at: string;
}
export interface FollowFeedSource {
kind: "tag" | "pool";
id: number;
label: string;
}
export interface FollowFeedEntry {
post_id: number;
seen: boolean;
data: {
id?: number | null;
rating?: string | null;
tags?: Record<string, string[]>;
score?: { total?: number | null } | null;
fav_count?: number | null;
file?: {
width?: number | null;
height?: number | null;
ext?: string | null;
} | null;
preview?: { url?: string | null } | null;
};
sources: FollowFeedSource[];
}
export interface FollowFeed {
count: number;
page: number;
per_page: number;
has_next: boolean;
results: FollowFeedEntry[];
}
export interface FollowCloud {
status: "ready" | "building";
stale: boolean;
tags: [string, number][];
posts: number;
source: "user" | "default" | null;
blacklist_count: number;
computed_at: string | null;
}
export interface E621IqdbCandidate {
post_id: number | null;
score: number | null;