Compare commits

..

30 Commits

Author SHA1 Message Date
bvandeusen 37e8b796a1 Merge pull request 'v26.05.27.0: PostCard redesign + IR-style tag suffix + drop meta/rating + extension v1.0.4 CSP fix' (#28) from dev into main 2026-05-27 11:31:18 -04:00
bvandeusen 8675f105ad fix(tests): test_api_tags prefix tests use character: not artist: (KNOWN_KINDS dropped artist)
The two prefix-parsing tests were pinned to `artist:Eric`, but `artist`
was removed from KNOWN_KINDS in commit 4cad07a (provenance is a separate
axis from tags). The parser now keeps `artist:` literal, so the assertion
`body["name"] == "Eric"` failed.

Repointed to `character:Saber` (still in KNOWN_KINDS). Also updated the
stale `artist:` docstring example in parse_kind_prefix to `fandom:`.

Caught by [[reference-grep-pinned-tests-in-plans]] — should have grep'd
tests/ for `artist:` when shrinking KNOWN_KINDS. Banking the miss.
2026-05-27 11:09:04 -04:00
bvandeusen 74dac6b960 fix(extension+migration): MV3 CSP opt-out from upgrade-insecure-requests (v1.0.4) + alembic 0023 drops the ck_tag_fandom_requires_character check before the type swap
extension/manifest.json: add content_security_policy.extension_pages = "script-src 'self'; object-src 'self';" — explicitly omits the upgrade-insecure-requests directive that MV3 inherits by default. Without this, every fetch(http://curator.../...) silently upgrades to https:// at the browser layer (Sec-Fetch-Site=same-origin, NS_ERROR_GENERATE_FAILURE), regardless of about:config. Bump XPI version 1.0.3 → 1.0.4 so a fresh signed build replaces the cached one. Operator-troubleshot 2026-05-26 via Inspect-the-extension dev tools showing the silent scheme upgrade.

alembic 0023: drop ck_tag_fandom_requires_character before the tag_kind type swap and recreate after. Postgres can't resolve `kind = 'character'` across the rename (column on tag_kind_old, literal binds to new tag_kind → "operator does not exist"). Same dance on downgrade. Banked under reference_tag_kind_enum_swap_check_drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:57:38 -04:00
bvandeusen 9e19c081b0 fix(test): pin tag_kind enum test to the post-0023 set (meta + rating removed)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:23:12 -04:00
bvandeusen 3838f04c16 feat(tag-kinds): drop meta + rating entirely — alembic 0023 deletes existing meta/rating tags (CASCADE clears related image_tag / alias / allowlist / suggestion_rejection / reference_embedding / series_page rows) then recreates the tag_kind ENUM without those values. Python TagKind enum trimmed; KIND_OPTIONS + KIND_COLOR + KIND_ICONS maps + TagsView KINDS array all updated. Operator confirmed they have no use for the data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:07:31 -04:00
bvandeusen 42b1340324 fix(tag-prefix): drop artist/meta/rating from KNOWN_KINDS — artist tags retired in FC-2d-vii-c (provenance is its own axis), meta/rating retired by operator 2026-05-26. User-typeable prefixes now just character/fandom/series. Frontend placeholder + icon map + client-side mirror updated; new test confirms retired prefixes parse as literal text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:00:12 -04:00
bvandeusen 3b1e2f1ceb feat(tag-input): IR-style kind:name suffix — drop the kind dropdown from TagAutocomplete; client-side parser mirrors backend's parse_kind_prefix (KNOWN_KINDS = artist/character/fandom/series/meta/rating); autocomplete searches across all kinds and shows kind chip in results; Create label uses parsed kind; character flow still goes through FandomPicker
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:54:11 -04:00
bvandeusen 8cdf0af0e1 feat(tags-api): IR-style kind:name parsing at POST /api/tags — when caller doesn't supply explicit kind, parse_kind_prefix runs on the name (artist:Eric → kind=artist, name='Eric'); explicit kind always wins for backward-compat; falls back to general when no recognized prefix is present. Updates the old "missing required" test that assumed kind was mandatory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:53:27 -04:00
bvandeusen ccee344099 feat(tag-prefix): parse_kind_prefix util — IR-style \kind:name\ parser at the input boundary; KNOWN_KINDS = artist/character/fandom/series/meta/rating (excludes default \general\ and system-managed archive/post)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:51:49 -04:00
bvandeusen 0316f92e8b feat(artist-posts-tab): bump max-width 900 → 1600 so the new wide-layout PostCard has room and the artist Posts feed doesn't leave most of an ultra-wide screen empty
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:51:21 -04:00
bvandeusen 6df74683b3 feat(post-card): responsive redesign — container-query split (stack <800px / side-by-side ≥800px), hero + thumb rail, +N overflow chip, line-clamp body (3 narrow / 5 wide), title/desc fallbacks for sparse data, click→postModal.open
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:51:10 -04:00
bvandeusen 243e536225 feat(app): mount PostModal at app root next to ImageViewer — single instance driven by usePostModalStore so PostCard can open from anywhere
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:50:34 -04:00
bvandeusen 2f16699971 feat(post-modal): PostModal — full Patreon-style v-dialog (header + image grid + sanitized body + attachments); reads from usePostModalStore
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:49:59 -04:00
bvandeusen a5cb684d34 feat(post-modal): PostImageGrid — fixed-cell grid (auto-fill 220px+, 4:3 aspect-cover) inside PostModal; click opens ImageViewer scoped to the post's images via modalStore.open(id, { postImageIds })
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:49:20 -04:00
bvandeusen 965a953b2e feat(post-card): PostEmptyThumbs — dashed-border placeholder shown in PostCard's hero slot when post has zero linked images
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:49:02 -04:00
bvandeusen 90c176b195 feat(postmodal-store): Pinia store driving the app-level PostModal — open(post) fetches full detail via posts store; close() clears
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:48:49 -04:00
bvandeusen b8d89b9f2a feat(modal-store): post-scoped cycle — open(id, { postImageIds }) pins prev/next to the array; canPrev/canNext + goPrev/goNext check the array index instead of current.value.neighbors when set. Gallery-context callers unchanged (default args clear scope)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:48:30 -04:00
bvandeusen 07344e0843 feat(util): htmlSanitize — whitelist-based DOM scrubber for PostModal's description v-html (Patreon ships HTML; sanitize before render)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:47:36 -04:00
bvandeusen 42c33e44f9 feat(post-api): get_post returns uncapped thumbnails — PostModal masonry needs full image list; feed query unchanged (still capped at 6 for previews). _thumbnails_for gains a limit kwarg; get_post passes limit=None.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:47:18 -04:00
bvandeusen 4e82208926 Merge pull request 'v26.05.26.5 — extension CORS unblock + UI gap closes + CI workflow cleanup' (#27) from dev into main 2026-05-26 20:15:07 -04:00
bvandeusen 85b640f32e fix(views): close the 24-32px gap below TopNav across all views — every v-container had py-6 (or py-8 on PlaceholderView) which pushed the first content item well below where the TopNav's fade-to-transparent gradient bottoms out. Switch to pt-2 pb-6 (8px top, 24px bottom) so content sits comfortably right below the nav, matches the ArtistHeader's 'continuous with TopNav' feel. PlaceholderView uses pt-3 pb-8 keeping its larger bottom padding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:47:23 -04:00
bvandeusen c7001f4aed fix(extension): CORS preflight for moz-extension:// + chrome-extension:// origins — operator-flagged 2026-05-26 that the extension's Test connection returned NetworkError because /api/credentials POSTs with X-Extension-Key trigger a browser preflight OPTIONS that hit a 405 (no OPTIONS method registered) with no Access-Control-Allow-* headers. Adds two app-level hooks: before_request short-circuits OPTIONS from extension origins with 204, after_request stamps the necessary ACL headers on responses to extension-origin requests. Whitelist is intentionally narrow (extension schemes only) so normal browser usage doesn't get permissive CORS. Five integration tests pin the contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:43:31 -04:00
bvandeusen f827612930 fix(artist-header): close gap below TopNav (top:64px → 48px to match TopNav's actual ~48px height) + center the tab strip via 1fr|auto|1fr layout with a right-side spacer cell
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:36:19 -04:00
bvandeusen 3f0153cba5 ci(workflows): dedupe + versioned image tags
ci.yml: drop pull_request: trigger — push: branches: [dev, main] already covers it; pull_request was duplicating ci.yml runs on every dev push with an open PR. (No fork PRs in this repo.)

build.yml: drop dev from push triggers — operator doesn't use the :dev image. Add tags: ['v*'] trigger + tag-push branch in the Determine-tag logic so cutting a release tag publishes an immutable :v26.05.26.X image (rollback story) without re-publishing :latest. Extend the XPI-download step to fire on tag pushes too so the versioned image carries the signed extension.

Net per hotfix cycle: 5 runs → 3 (no tag) / 4 (with tag).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:26:56 -04:00
bvandeusen 52fff00353 Merge pull request 'v26.05.26.4 — hotfix: migration 0022 pre-DELETE colliding ImageProvenance before UPDATE' (#26) from dev into main 2026-05-26 18:06:20 -04:00
bvandeusen f3e8f30a8f fix(migration-0022): pre-DELETE colliding image_provenance rows before the UPDATE post_id — same row-by-row UNIQUE pattern as the post-collision case, just one level deeper. When image X has provenance under both keep and drop, UPDATE drop→keep would fire uq_image_provenance_image_post on the row that'd collide with the existing (X, keep). Pre-delete those rows (their info is already represented by the keep-side provenance) before the UPDATE moves the rest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:05:49 -04:00
bvandeusen eee107766e fix(migration-0022): rename unused _epid loop var (ruff B007)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:03:11 -04:00
bvandeusen c14338cbce Merge pull request 'v26.05.26.3 — hotfix: migration 0022 pre-merge across ENTIRE (canonical+others) group' (#25) from dev into main 2026-05-26 17:52:59 -04:00
bvandeusen 7a64730bd2 fix(migration-0022): pre-merge ALL duplicate-external_post_id Posts across the (canonical+others) group, not just canonical-vs-others — operator's v26.05.26.2 deploy still tripped uq_post_source_external_id because two non-canonical Sources both had Posts with epid=6166997. Bulk UPDATE moved the first cleanly then collided on the second. New pre-merge groups all Posts in the (artist, platform) by external_post_id; for any group with count>1, picks the keep (prefer one under canonical; else lowest id) and merges the rest before the bulk reparent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:52:29 -04:00
bvandeusen 1803a09306 ci(workflow): remove the 4 Cache pip wheels steps entirely — act_runner's cache backend has been broken for 11+ days and the cached path (~/.cache/pip) wasn't even the primary install tool's cache anyway (uv uses ~/.cache/uv). Net cost ~30s/job of wheel downloads. Long-term: mount ~/.cache/uv as a docker volume at the runner level (skips actions/cache entirely) or fix the runner-side cache backend.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:54:05 -04:00
40 changed files with 1370 additions and 350 deletions
+38 -5
View File
@@ -2,7 +2,17 @@ name: Build images
on:
push:
branches: [dev, main]
# `:dev` builds dropped 2026-05-26 — operator tests from `:latest` after
# merge-to-main, not from the dev branch image. Saves one full docker
# build per dev push.
branches: [main]
# Tag-push triggers an immutable per-version image build (e.g.
# `:v26.05.26.5`) — gives a real rollback story alongside the floating
# `:main` / `:latest`. Layer reuse keeps the registry-storage cost
# negligible per tag. Doesn't overlap with the push-to-main build (that
# one publishes `:main` + `:latest`; the tag-push build publishes only
# `:<tag>`).
tags: ['v*']
# Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes:
# - write:package, read:package (for docker push to git.fabledsword.com)
@@ -158,8 +168,12 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Download signed XPI from Forgejo release asset (main only)
if: github.ref == 'refs/heads/main'
- name: Download signed XPI from Forgejo release asset (main + tags)
# Fires on main-push AND on tag-push. Tag-push builds re-package the
# same source code as the preceding main-push build but with an
# immutable version tag — they need the XPI too, otherwise the
# versioned image ships without the signed extension.
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
@@ -200,7 +214,20 @@ jobs:
- name: Determine tag
id: tag
run: |
if [ "${GITHUB_REF##*/}" = "main" ]; then
# Three trigger shapes:
# refs/tags/v… → tag-push: publish ONLY the immutable version
# tag (e.g. :v26.05.26.5). Don't touch :latest;
# that already got published by the main-push
# build for the merge commit.
# refs/heads/main → push to main (incl. PR merge commits):
# publish :main + :latest (floating).
# anything else → safety net; shouldn't fire given the `on:`
# config above (dev was dropped). Tag :dev to
# surface the unexpected run in the registry.
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
TAG_NAME="${GITHUB_REF#refs/tags/}"
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT"
elif [ "${GITHUB_REF##*/}" = "main" ]; then
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest" >> "$GITHUB_OUTPUT"
else
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
@@ -231,7 +258,13 @@ jobs:
- name: Determine tag
id: tag
run: |
if [ "${GITHUB_REF##*/}" = "main" ]; then
# Mirrors build-web's three-shape logic (tag-push / main-push /
# safety-net dev). The -ml image follows the same release cadence
# as the web image.
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
TAG_NAME="${GITHUB_REF#refs/tags/}"
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT"
elif [ "${GITHUB_REF##*/}" = "main" ]; then
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest" >> "$GITHUB_OUTPUT"
else
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
+15 -66
View File
@@ -8,8 +8,10 @@ name: CI
on:
push:
branches: [dev, main]
pull_request:
branches: [main]
# pull_request trigger intentionally absent — with branches: [dev, main]
# above, every PR commit already fires CI via the push event on dev. Adding
# pull_request would duplicate runs on dev→main PRs. FC has no fork PRs
# (single-operator Forgejo repo) so push coverage is complete.
jobs:
backend-lint-and-test:
@@ -24,22 +26,17 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Cache pip wheels
# continue-on-error: act_runner's cache backend has been broken on
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
# but the action's JS bundle now fails to load at all
# ("Cannot find module .../dist/restore/index.js"), hard-failing
# the whole job. The install step that follows handles cold caches
# natively (uv pip / pip install both work without it), so net
# cost of disabling the cache here is ~30s of wheel downloads per
# job. Re-enable once the runner-side cache backend is fixed.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-py314-
# Cache step removed 2026-05-26: act_runner's cache backend has been
# broken on this homelab runner since 2026-05-15 (first as request-
# timeout warnings, then as hard "Cannot find module .../dist/restore/
# index.js" failures that tank the whole job). The cache step targeted
# ~/.cache/pip but the install below uses `uv pip install` primarily,
# whose own cache lives at ~/.cache/uv — so the cache step's real
# benefit was marginal even when working. Cost of removal: ~30s of
# wheel downloads per job. Future re-enable: mount ~/.cache/uv as a
# docker volume at the runner level (skips actions/cache entirely),
# or fix the runner-side cache backend (clear /var/run/act/actions/*,
# pin act_runner version, etc.).
- name: Install Python deps
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
@@ -133,22 +130,6 @@ jobs:
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Cache pip wheels
# continue-on-error: act_runner's cache backend has been broken on
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
# but the action's JS bundle now fails to load at all
# ("Cannot find module .../dist/restore/index.js"), hard-failing
# the whole job. The install step that follows handles cold caches
# natively (uv pip / pip install both work without it), so net
# cost of disabling the cache here is ~30s of wheel downloads per
# job. Re-enable once the runner-side cache backend is fixed.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-py314-
- name: API integration shard (resolve service IPs, migrate, test)
run: |
set -eux
@@ -207,22 +188,6 @@ jobs:
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Cache pip wheels
# continue-on-error: act_runner's cache backend has been broken on
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
# but the action's JS bundle now fails to load at all
# ("Cannot find module .../dist/restore/index.js"), hard-failing
# the whole job. The install step that follows handles cold caches
# natively (uv pip / pip install both work without it), so net
# cost of disabling the cache here is ~30s of wheel downloads per
# job. Re-enable once the runner-side cache backend is fixed.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-py314-
- name: Importer integration shard (resolve service IPs, migrate, test)
run: |
set -eux
@@ -281,22 +246,6 @@ jobs:
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Cache pip wheels
# continue-on-error: act_runner's cache backend has been broken on
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
# but the action's JS bundle now fails to load at all
# ("Cannot find module .../dist/restore/index.js"), hard-failing
# the whole job. The install step that follows handles cold caches
# natively (uv pip / pip install both work without it), so net
# cost of disabling the cache here is ~30s of wheel downloads per
# job. Re-enable once the runner-side cache backend is fixed.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-py314-
- name: Core integration shard (everything not api / importer / migration / phash / sidecar / scan / archive / backfill)
run: |
set -eux
@@ -83,55 +83,80 @@ def upgrade() -> None:
if not other_ids:
continue
# STEP 2: PRE-merge colliding Posts BEFORE the bulk reparent.
# Find pairs (keep, drop) where:
# keep = a Post under the canonical source
# drop = a Post under one of the non-canonical sources whose
# external_post_id equals keep.external_post_id
# If we let the bulk UPDATE try to repoint drop onto canonical,
# uq_post_source_external_id fires row-by-row and aborts the
# whole migration.
colliding = conn.execute(
# STEP 2: PRE-merge ALL Posts with duplicate external_post_id
# across the entire (canonical + others) group, BEFORE the bulk
# reparent. Two cases must both be handled:
# (A) canonical has Post X with epid=N; an "other" source has
# Post Y with epid=N → after bulk UPDATE, (canonical, N)
# collides with itself.
# (B) two different "other" sources each have a Post with
# epid=N; canonical has none → after bulk UPDATE, both
# are repointed to (canonical, N) and the second collides.
# The earlier version of this migration only handled (A); the
# operator's deploy 2026-05-26 tripped (B) at line 139.
# Fix: group ALL Posts in the (artist, platform) by epid; for
# any group with count>1, pick the keep (prefer one already
# under canonical; else lowest id) and merge the rest into it.
all_posts = conn.execute(
text("""
SELECT keep.id AS keep_id, drop_.id AS drop_id
FROM post AS keep
JOIN post AS drop_
ON drop_.external_post_id = keep.external_post_id
AND drop_.id != keep.id
WHERE keep.source_id = :canonical
AND drop_.source_id = ANY(:others)
SELECT external_post_id, id, source_id
FROM post
WHERE source_id = :canonical OR source_id = ANY(:others)
ORDER BY external_post_id, id
"""),
{"canonical": canonical_id, "others": other_ids},
).fetchall()
for keep_id, drop_id in colliding:
conn.execute(
text("""
UPDATE image_provenance SET post_id = :keep
WHERE post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("""
UPDATE image_record SET primary_post_id = :keep
WHERE primary_post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
# Repointed provenance may now collide on
# uq_image_provenance_image_post (alembic 0021). Dedupe:
# keep min(id) per (image_record_id, post_id).
conn.execute(text("""
DELETE FROM image_provenance ip1
USING image_provenance ip2
WHERE ip1.image_record_id = ip2.image_record_id
AND ip1.post_id = ip2.post_id
AND ip1.id > ip2.id
"""))
conn.execute(
text("DELETE FROM post WHERE id = :drop_"),
{"drop_": drop_id},
)
by_epid: dict = {}
for epid, post_id, src_id in all_posts:
by_epid.setdefault(epid, []).append((post_id, src_id))
for _epid, posts in by_epid.items():
if len(posts) <= 1:
continue
# Prefer a Post already under canonical as the keep.
canonical_posts = [p for p in posts if p[1] == canonical_id]
if canonical_posts:
keep_id = canonical_posts[0][0]
else:
keep_id = posts[0][0] # already sorted by id ASC
drop_ids = [p[0] for p in posts if p[0] != keep_id]
for drop_id in drop_ids:
# Pre-delete image_provenance rows under drop_ whose
# image_record_id ALREADY has a provenance under keep —
# the UPDATE below would otherwise repoint them and
# trip uq_image_provenance_image_post (alembic 0021)
# row-by-row before any after-the-fact dedupe could
# run. Operator's v26.05.26.3 deploy 2026-05-26 tripped
# this at line 123.
conn.execute(
text("""
DELETE FROM image_provenance
WHERE post_id = :drop_
AND image_record_id IN (
SELECT image_record_id FROM image_provenance
WHERE post_id = :keep
)
"""),
{"keep": keep_id, "drop_": drop_id},
)
# Now safe to repoint the survivors.
conn.execute(
text("""
UPDATE image_provenance SET post_id = :keep
WHERE post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("""
UPDATE image_record SET primary_post_id = :keep
WHERE primary_post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("DELETE FROM post WHERE id = :drop_"),
{"drop_": drop_id},
)
# STEP 3: Bulk reparent the remaining Posts off the other
# Sources. After step 2, no collisions on
@@ -0,0 +1,99 @@
"""drop meta + rating tag kinds — operator-retired 2026-05-26
Revision ID: 0023
Revises: 0022
Create Date: 2026-05-26
Operator decided meta + rating aren't valid tag kinds for FC. Per-row
behavior: DELETE existing rows (operator chose "clean break" over
"convert to general"). All cascading FKs (image_tag, tag_alias,
tag_allowlist, tag_reference_embedding, tag_suggestion_rejection,
series_page) use ondelete="CASCADE" so a single DELETE on tag cleans
the related rows in one go.
After the data cleanup, recreate the tag_kind ENUM without 'meta' /
'rating' (Postgres has no `ALTER TYPE ... DROP VALUE`; standard
rename-create-cast-drop dance). The server default 'general' is
dropped before the type swap and restored after.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0023"
down_revision: Union[str, None] = "0022"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Delete tags of the retired kinds. CASCADE handles related tables.
op.execute("DELETE FROM tag WHERE kind IN ('meta', 'rating')")
# 2. Drop the CHECK constraint that references the enum's literal
# values. Postgres can't resolve `kind = 'character'` across the
# type swap below — the literal would bind to the new tag_kind
# but the column is on tag_kind_old, producing
# "operator does not exist: tag_kind = tag_kind_old".
# (Operator-hit during the v26.05.26.5 deploy attempt; ck was
# originally added by alembic 0002.) Recreated post-swap.
op.drop_constraint(
"ck_tag_fandom_requires_character", "tag", type_="check"
)
# 3. Drop the server default — ALTER COLUMN TYPE can't carry it
# across the type swap below.
op.execute("ALTER TABLE tag ALTER COLUMN kind DROP DEFAULT")
# 4. Recreate the tag_kind enum without meta/rating.
op.execute("ALTER TYPE tag_kind RENAME TO tag_kind_old")
op.execute(
"CREATE TYPE tag_kind AS ENUM ("
"'artist', 'character', 'fandom', 'general', "
"'series', 'archive', 'post'"
")"
)
op.execute(
"ALTER TABLE tag "
"ALTER COLUMN kind TYPE tag_kind "
"USING kind::text::tag_kind"
)
op.execute("DROP TYPE tag_kind_old")
# 5. Restore the server default.
op.execute("ALTER TABLE tag ALTER COLUMN kind SET DEFAULT 'general'")
# 6. Restore the CHECK constraint (now bound to the new tag_kind).
op.create_check_constraint(
"ck_tag_fandom_requires_character",
"tag",
"(fandom_id IS NULL) OR (kind = 'character')",
)
def downgrade() -> None:
# Add the values back to the enum so old code can boot. The deleted
# tag rows are gone permanently — no safe restore.
op.drop_constraint(
"ck_tag_fandom_requires_character", "tag", type_="check"
)
op.execute("ALTER TABLE tag ALTER COLUMN kind DROP DEFAULT")
op.execute("ALTER TYPE tag_kind RENAME TO tag_kind_old")
op.execute(
"CREATE TYPE tag_kind AS ENUM ("
"'artist', 'character', 'fandom', 'general', "
"'series', 'archive', 'post', 'meta', 'rating'"
")"
)
op.execute(
"ALTER TABLE tag "
"ALTER COLUMN kind TYPE tag_kind "
"USING kind::text::tag_kind"
)
op.execute("DROP TYPE tag_kind_old")
op.execute("ALTER TABLE tag ALTER COLUMN kind SET DEFAULT 'general'")
op.create_check_constraint(
"ck_tag_fandom_requires_character",
"tag",
"(fandom_id IS NULL) OR (kind = 'character')",
)
+38 -1
View File
@@ -3,13 +3,23 @@
import logging
from pathlib import Path
from quart import Quart
from quart import Quart, request
from .api import all_blueprints
from .config import get_config
from .frontend import frontend_bp
from .services.credential_crypto import CredentialCrypto
# Browser-extension origins. The FabledCurator extension fetches from
# moz-extension://<uuid>/ on Firefox and chrome-extension://<uuid>/ on
# Chromium-based browsers. Operator-flagged 2026-05-26: extension's
# 'Test connection' returned `NetworkError` because the X-Extension-Key
# header on /api/credentials triggers a CORS preflight that our routes
# don't handle. Whitelisting only these two schemes (not opening CORS
# up generally) lets the extension talk to a plain-HTTP self-hosted FC
# without weakening the no-CORS posture for normal browser usage.
_EXTENSION_ORIGIN_SCHEMES = ("moz-extension://", "chrome-extension://")
_CREDENTIAL_KEY_PATH = Path("/images/secrets/credential_key.b64")
@@ -35,6 +45,33 @@ def create_app() -> Quart:
# Registered last so /api/* routes win over the SPA catch-all.
app.register_blueprint(frontend_bp)
@app.before_request
async def _extension_cors_preflight():
# Short-circuit OPTIONS preflight from the browser extension with a
# 204 + CORS headers (the after_request hook below adds them).
# Without this, OPTIONS lands on routes that only declared POST/GET
# methods and 405s before the after_request gets a chance.
if request.method != "OPTIONS":
return None
origin = request.headers.get("Origin", "")
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
return "", 204
return None
@app.after_request
async def _extension_cors_headers(response):
origin = request.headers.get("Origin", "")
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Methods"] = (
"GET, POST, PATCH, DELETE, OPTIONS"
)
response.headers["Access-Control-Allow-Headers"] = (
"Content-Type, X-Extension-Key"
)
response.headers["Access-Control-Max-Age"] = "86400"
return response
@app.after_serving
async def _dispose_db_engine() -> None:
from .extensions import dispose_engine
+32 -5
View File
@@ -15,6 +15,7 @@ from ..services.tag_service import (
TagService,
TagValidationError,
)
from ..utils.tag_prefix import parse_kind_prefix
tags_bp = Blueprint("tags", __name__, url_prefix="/api")
@@ -105,13 +106,39 @@ async def directory():
@tags_bp.route("/tags", methods=["POST"])
async def create_tag():
"""Create a tag. Two input shapes accepted:
1. Explicit: {name, kind, fandom_id?} — caller already split, kind wins.
2. IR-suffix: {name} where name = "kind:Name" (e.g. "artist:Eric").
The server runs parse_kind_prefix(name) to derive kind; the colon
and prefix are stripped from the stored tag name. If no recognized
prefix is present, the kind defaults to `general`.
Explicit kind ALWAYS wins (backward-compat for existing callers).
"""
body = await request.get_json()
if not body or "name" not in body or "kind" not in body:
return jsonify({"error": "name and kind required"}), 400
if not body or "name" not in body:
return jsonify({"error": "name required"}), 400
name = body["name"]
kind = _coerce_kind(body["kind"])
if kind is None:
return jsonify({"error": f"invalid kind {body['kind']!r}"}), 400
explicit_kind_raw = body.get("kind")
if explicit_kind_raw is not None:
# Caller provided kind — honor it; don't re-parse.
kind = _coerce_kind(explicit_kind_raw)
if kind is None:
return jsonify({"error": f"invalid kind {explicit_kind_raw!r}"}), 400
else:
# IR-style: parse "kind:Name" from the raw name.
parsed_kind, parsed_name = parse_kind_prefix(name)
if parsed_kind is not None:
name = parsed_name
kind = _coerce_kind(parsed_kind)
# parse_kind_prefix only returns kinds from KNOWN_KINDS which
# are all valid TagKind members, so _coerce_kind can't return
# None here — but defensive.
if kind is None:
return jsonify({"error": f"invalid kind {parsed_kind!r}"}), 400
else:
kind = TagKind.general
fandom_id = body.get("fandom_id")
async with get_session() as session:
+4 -2
View File
@@ -35,8 +35,10 @@ class TagKind(StrEnum):
series = "series"
archive = "archive"
post = "post"
meta = "meta"
rating = "rating"
# `meta` and `rating` retired by operator 2026-05-26 (alembic 0023).
# `artist` retired in FC-2d-vii-c — artists are first-class entities
# via Artist/Source rows now, not tags — but the enum value stays
# to keep historic tag rows queryable.
image_tag = Table(
+22 -12
View File
@@ -109,7 +109,10 @@ class PostFeedService:
if row is None:
return None
post, artist, source = row
thumbs_map = await self._thumbnails_for([post.id])
# Detail endpoint returns the FULL image list for PostModal's
# masonry grid — feed query still caps at THUMBNAIL_LIMIT via
# the default arg.
thumbs_map = await self._thumbnails_for([post.id], limit=None)
atts_map = await self._attachments_for([post.id])
item = self._to_dict(post, artist, source, thumbs_map, atts_map)
item["description_full"] = html_to_plain(post.description)
@@ -117,15 +120,21 @@ class PostFeedService:
# --- composition helpers ---------------------------------------------
async def _thumbnails_for(self, post_ids: list[int]) -> dict[int, dict]:
"""post_id -> {"thumbs": [...up to 6], "more": int}.
async def _thumbnails_for(
self, post_ids: list[int], *, limit: int | None = THUMBNAIL_LIMIT,
) -> dict[int, dict]:
"""post_id -> {"thumbs": [...up to limit], "more": int}.
Selects THUMBNAIL_LIMIT+1 images per post via window function so we
can detect overflow in a single query.
Selects up to `limit` images per post via window function so we
can detect overflow in a single query. Pass `limit=None` to
return ALL thumbnails per post (used by `get_post` for PostModal's
masonry grid; the feed pass keeps the default cap so payloads
stay small).
"""
if not post_ids:
return {}
# Rank images within each post and fetch only the top THUMBNAIL_LIMIT+1.
# Rank images within each post; cap at `limit` rows per post when
# limit is set, return all when limit is None.
ranked = (
select(
ImageRecord.id,
@@ -143,12 +152,13 @@ class PostFeedService:
.where(ImageRecord.primary_post_id.in_(post_ids))
.subquery()
)
rows = (await self.session.execute(
select(
ranked.c.id, ranked.c.primary_post_id,
ranked.c.sha256, ranked.c.mime, ranked.c.total,
).where(ranked.c.rn <= THUMBNAIL_LIMIT)
)).all()
stmt = select(
ranked.c.id, ranked.c.primary_post_id,
ranked.c.sha256, ranked.c.mime, ranked.c.total,
)
if limit is not None:
stmt = stmt.where(ranked.c.rn <= limit)
rows = (await self.session.execute(stmt)).all()
out: dict[int, dict] = {pid: {"thumbs": [], "more": 0} for pid in post_ids}
for img_id, pid, sha, mime, total in rows:
+46
View File
@@ -0,0 +1,46 @@
"""Parse the user-facing `kind:name` shortcut used by the add-tag input.
Mirrors IR's app/utils/tag_prefix.py. Tag.name in FC is stored bare;
the `kind:` prefix only exists as an input convention at user-facing
places (image-modal add-tag input, future bulk-add forms). The parser
is the single owner of the kind-string list — anything not in
KNOWN_KINDS keeps its colon as literal text.
"""
from __future__ import annotations
# Kinds the user can type as a prefix at the input boundary.
# Exclusions:
# - `general` is the default for un-prefixed input (never typed as prefix)
# - `archive`, `post` are system-managed
# - `artist` was retired in FC-2d-vii-c — artists are first-class
# entities (Artist row + ImageRecord.artist_id), browsed via the
# provenance axis rather than as tags. See project_provenance_separation.
# - `meta`, `rating` retired as user-typeable per operator 2026-05-26 —
# content classification only needs character/fandom/series.
KNOWN_KINDS: frozenset[str] = frozenset({
"character",
"fandom",
"series",
})
def parse_kind_prefix(raw: str) -> tuple[str | None, str]:
"""Split a raw user-typed tag string into (kind, name).
Returns (kind, name) where kind is lowercase canonical and in
KNOWN_KINDS, or (None, raw.strip()) if no recognized prefix is
present. `name` is always whitespace-stripped.
Examples:
parse_kind_prefix("character:Saber") -> ("character", "Saber")
parse_kind_prefix("Character:Saber") -> ("character", "Saber")
parse_kind_prefix("sunset") -> (None, "sunset")
parse_kind_prefix("http://example") -> (None, "http://example")
parse_kind_prefix("fandom: FSN ") -> ("fandom", "FSN")
"""
if ":" in raw:
prefix, rest = raw.split(":", 1)
if prefix.lower() in KNOWN_KINDS:
return prefix.lower(), rest.strip()
return None, raw.strip()
+6 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "FabledCurator",
"version": "1.0.3",
"version": "1.0.4",
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
"browser_specific_settings": {
@@ -11,6 +11,11 @@
}
},
"content_security_policy": {
"_comment": "Override the MV3 default CSP to OMIT upgrade-insecure-requests. FC runs over plain HTTP per the homelab posture (feedback_homelab_http), and the default MV3 CSP would silently upgrade every fetch(http://curator.../...) to https:// and fail with NS_ERROR_GENERATE_FAILURE. Operator-flagged 2026-05-26 after the 'Test connection' button errored despite a working CORS preflight on the backend.",
"extension_pages": "script-src 'self'; object-src 'self';"
},
"permissions": [
"cookies",
"storage",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "fabledcurator-extension",
"version": "1.0.3",
"version": "1.0.4",
"private": true,
"description": "Firefox extension for FabledCurator",
"scripts": {
+2
View File
@@ -4,6 +4,7 @@
<RouterView />
</AppShell>
<ImageViewer v-if="modal.isOpen" @close="modal.close()" />
<PostModal />
<AppSnackbar ref="snackbar" />
</v-app>
</template>
@@ -13,6 +14,7 @@ import { onMounted, ref } from 'vue'
import AppShell from './components/AppShell.vue'
import AppSnackbar from './components/AppSnackbar.vue'
import ImageViewer from './components/modal/ImageViewer.vue'
import PostModal from './components/posts/PostModal.vue'
import { useModalStore } from './stores/modal.js'
const modal = useModalStore()
@@ -25,6 +25,11 @@
</v-tab>
<v-tab value="management">Management</v-tab>
</v-tabs>
<!-- Right-side spacer: balances the left cell's flex weight so the
centered tabs stay geometrically centered regardless of the
artist-name length. Mirrors TopNav's 1fr | auto | 1fr layout. -->
<div class="fc-artist-header__right" />
</header>
</template>
@@ -54,11 +59,13 @@ const stats = computed(() => {
</script>
<style scoped>
/* Matches TopNav.vue's frosted recipe exactly top:64px parks it under
the 64px-tall TopNav with no visible seam. */
/* Matches TopNav.vue's frosted recipe exactly. top:48px parks it flush
against TopNav's bottom edge (TopNav is 0.75rem padding + ~24px content
= ~48px tall; operator-flagged 2026-05-26 that top:64px left a visible
gap). The two bars now read as one continuous frosted strip. */
.fc-artist-header {
position: sticky;
top: 64px;
top: 48px;
z-index: 4;
display: flex;
align-items: center;
@@ -107,6 +114,11 @@ const stats = computed(() => {
flex: 0 0 auto;
}
.fc-artist-header__right {
flex: 1 1 0;
min-width: 0;
}
.fc-artist-header__tab-count {
margin-left: 4px;
font-size: 12px;
@@ -67,7 +67,7 @@ onUnmounted(() => {
<style scoped>
.fc-artist-posts {
max-width: 900px;
max-width: 1600px;
margin: 0 auto;
}
.fc-artist-posts__loading,
@@ -1,17 +1,18 @@
<template>
<div class="fc-tag-autocomplete">
<div class="d-flex" style="gap: 6px;">
<v-select
v-model="kind" :items="kindOptions" :item-title="(k) => k.label" :item-value="(k) => k.value"
density="compact" hide-details style="max-width: 140px;"
/>
<v-text-field
v-model="query" placeholder="Add tag…" density="compact" hide-details
@keydown.down.prevent="moveHighlight(1)" @keydown.up.prevent="moveHighlight(-1)"
@keydown.enter.prevent="onEnter" @keydown.esc="$emit('cancel')"
/>
</div>
<v-list v-if="hits.length || allowCreate" density="compact" class="fc-tag-autocomplete__list">
<v-text-field
v-model="query"
placeholder="Add tag (or kind:name — character/fandom/series)"
density="compact" hide-details
@keydown.down.prevent="moveHighlight(1)"
@keydown.up.prevent="moveHighlight(-1)"
@keydown.enter.prevent="onEnter"
@keydown.esc="$emit('cancel')"
/>
<v-list
v-if="hits.length || allowCreate"
density="compact" class="fc-tag-autocomplete__list"
>
<v-list-item
v-for="(h, idx) in hits" :key="h.id"
:active="idx === highlight" @click="onPick(h)"
@@ -26,7 +27,7 @@
<span v-if="h.fandom_name" class="text-caption"> {{ h.fandom_name }}</span>
</v-list-item-title>
<template #append>
<span class="text-caption">{{ h.image_count }}</span>
<span class="text-caption">{{ h.kind }}</span>
</template>
</v-list-item>
<v-list-item
@@ -34,9 +35,13 @@
@click="onCreate"
>
<template #prepend>
<v-icon size="small" :color="store.colorFor(kind)">{{ iconFor(kind) }}</v-icon>
<v-icon size="small" :color="store.colorFor(parsedKind)">
{{ iconFor(parsedKind) }}
</v-icon>
</template>
<v-list-item-title>Create "{{ query }}" as {{ kind }}</v-list-item-title>
<v-list-item-title>
Create "{{ parsedName }}" as {{ parsedKind }}
</v-list-item-title>
</v-list-item>
</v-list>
@@ -54,65 +59,95 @@ import FandomPicker from './FandomPicker.vue'
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
const store = useTagStore()
const kind = ref('general')
// Single text input; no kind dropdown. Client-side mirror of the
// backend's parse_kind_prefix lives below — kept in sync with
// KNOWN_KINDS in backend/app/utils/tag_prefix.py. The backend is the
// canonical parser; this mirror just powers the live Create-label
// preview ("Create 'Eric' as artist") and the autocomplete query.
const query = ref('')
const hits = ref([])
const highlight = ref(0)
const fandomDialog = ref(false)
let pendingNewName = null
const kindOptions = store.kindOptions()
const KNOWN_KINDS = new Set([
'character', 'fandom', 'series',
])
const KIND_ICONS = {
general: 'mdi-tag', artist: 'mdi-palette', character: 'mdi-account-circle',
general: 'mdi-tag', character: 'mdi-account-circle',
fandom: 'mdi-book-open-page-variant', series: 'mdi-bookshelf',
meta: 'mdi-cog-outline', rating: 'mdi-shield-check-outline'
}
function iconFor(k) { return KIND_ICONS[k] || 'mdi-tag' }
function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
const parsed = computed(() => {
const raw = query.value.trim()
if (raw.includes(':')) {
const idx = raw.indexOf(':')
const prefix = raw.slice(0, idx).toLowerCase()
if (KNOWN_KINDS.has(prefix)) {
return { kind: prefix, name: raw.slice(idx + 1).trim() }
}
}
return { kind: 'general', name: raw }
})
const parsedKind = computed(() => parsed.value.kind)
const parsedName = computed(() => parsed.value.name)
let debounceId = null
watch([query, kind], () => {
watch(query, () => {
highlight.value = 0
if (debounceId) clearTimeout(debounceId)
debounceId = setTimeout(async () => {
const q = query.value.trim()
const q = parsedName.value
if (!q) { hits.value = []; return }
hits.value = await store.autocomplete(q, kind.value, 10)
// Autocomplete across ALL kinds. When the user typed a prefix the
// matches list is naturally narrower because the parsed name is
// shorter; we don't filter server-side by kind.
hits.value = await store.autocomplete(q, null, 10)
}, 200)
})
const allowCreate = computed(() => {
const q = query.value.trim()
return q && !hits.value.some(h => h.name.toLowerCase() === q.toLowerCase() && h.kind === kind.value)
const q = parsedName.value
if (!q) return false
return !hits.value.some(h =>
h.name.toLowerCase() === q.toLowerCase() && h.kind === parsedKind.value,
)
})
function moveHighlight(delta) {
function moveHighlight (delta) {
const total = hits.value.length + (allowCreate.value ? 1 : 0)
if (total === 0) return
highlight.value = (highlight.value + delta + total) % total
}
function onPick(hit) { emit('pick-existing', hit); reset() }
function onPick (hit) { emit('pick-existing', hit); reset() }
function onCreate() {
const name = query.value.trim()
if (kind.value === 'character') {
// Character requires a fandom — open the picker.
function onCreate () {
const name = parsedName.value
const kind = parsedKind.value
if (kind === 'character') {
pendingNewName = name
fandomDialog.value = true
return
}
emit('pick-new', { name, kind: kind.value, fandom_id: null })
// Pass explicit kind here; the backend accepts both shapes. Passing
// it makes the parsed kind preview match the actual server outcome
// for users who didn't use a prefix (general goes through cleanly).
emit('pick-new', { name, kind, fandom_id: null })
reset()
}
function onFandomChosen(fandom) {
function onFandomChosen (fandom) {
fandomDialog.value = false
emit('pick-new', { name: pendingNewName, kind: 'character', fandom_id: fandom.id })
emit('pick-new', {
name: pendingNewName, kind: 'character', fandom_id: fandom.id,
})
pendingNewName = null
reset()
}
function onEnter() {
function onEnter () {
if (highlight.value < hits.value.length) {
onPick(hits.value[highlight.value])
} else if (allowCreate.value) {
@@ -120,7 +155,7 @@ function onEnter() {
}
}
function reset() { query.value = ''; hits.value = []; highlight.value = 0 }
function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
</script>
<style scoped>
+172 -122
View File
@@ -1,10 +1,17 @@
<template>
<v-card class="fc-post-card" variant="outlined">
<v-card
class="fc-post-card"
variant="outlined"
tabindex="0"
@click="onCardClick"
@keydown.enter="onCardClick"
>
<div class="fc-post-card__head">
<v-chip size="x-small" variant="tonal">{{ post.source.platform }}</v-chip>
<RouterLink
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
class="fc-post-card__artist"
@click.stop
>{{ post.artist.name }}</RouterLink>
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
<v-spacer />
@@ -13,72 +20,74 @@
:href="post.post_url" target="_blank" rel="noopener"
icon="mdi-open-in-new" size="x-small" variant="text"
:aria-label="`open original post on ${post.source.platform}`"
@click.stop
/>
</div>
<h3 v-if="post.post_title" class="fc-post-card__title">{{ post.post_title }}</h3>
<div class="fc-post-card__body">
<div class="fc-post-card__media">
<template v-if="post.thumbnails?.length">
<div class="fc-post-card__hero">
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" />
</div>
<div v-if="rail.length" class="fc-post-card__rail">
<div v-for="t in rail" :key="t.image_id" class="fc-post-card__rail-cell">
<img :src="t.thumbnail_url" :alt="`thumbnail`" loading="lazy" />
</div>
<div v-if="moreCount > 0" class="fc-post-card__rail-more">
+{{ moreCount }}
</div>
</div>
</template>
<PostEmptyThumbs v-else />
</div>
<div v-if="descriptionToShow" class="fc-post-card__desc">
<span class="fc-post-card__desc-text">{{ descriptionToShow }}</span>
<button
v-if="post.description_truncated && !expanded"
class="fc-post-card__more" type="button"
@click="expand"
>Show more</button>
<button
v-if="expanded"
class="fc-post-card__more" type="button"
@click="expanded = false"
>Show less</button>
</div>
<div class="fc-post-card__text">
<h3 v-if="post.post_title" class="fc-post-card__title">
{{ post.post_title }}
</h3>
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
Post {{ post.external_post_id }}
</h3>
<div v-if="post.thumbnails.length" class="fc-post-card__thumbs">
<RouterLink
v-for="t in post.thumbnails"
:key="t.image_id"
:to="{ path: '/gallery', query: { post_id: post.id } }"
class="fc-post-card__thumb"
>
<v-img
:src="t.thumbnail_url" cover :alt="`thumbnail`"
width="96" height="96" class="fc-post-card__thumb-img"
/>
</RouterLink>
<RouterLink
v-if="post.thumbnails_more > 0"
:to="{ path: '/gallery', query: { post_id: post.id } }"
class="fc-post-card__more-thumbs"
>+{{ post.thumbnails_more }} more</RouterLink>
</div>
<p v-if="post.description_plain" class="fc-post-card__desc">
{{ post.description_plain }}
</p>
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
(no description)
</p>
<div v-if="post.attachments.length" class="fc-post-card__attachments">
<a
v-for="att in post.attachments"
:key="att.id"
:href="att.download_url"
download
class="fc-post-card__att"
>
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
<span class="fc-post-card__att-name">{{ att.original_filename }}</span>
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
</a>
<div v-if="post.attachments?.length" class="fc-post-card__atts">
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
{{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }}
</div>
</div>
</div>
</v-card>
</template>
<script setup>
import { computed, ref } from 'vue'
import { usePostsStore } from '../../stores/posts.js'
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { usePostModalStore } from '../../stores/postModal.js'
import PostEmptyThumbs from './PostEmptyThumbs.vue'
const props = defineProps({
post: { type: Object, required: true },
})
const postsStore = usePostsStore()
const postModal = usePostModalStore()
const expanded = ref(false)
const fullDescription = ref(null)
const hero = computed(() => props.post.thumbnails?.[0])
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4))
const moreCount = computed(() => {
const more = props.post.thumbnails_more || 0
const railLen = rail.value.length
// If feed returned more than (1 hero + 3 rail = 4), extra spills into "+N".
const extraShown = Math.max(0, (props.post.thumbnails?.length || 0) - 1 - railLen)
return more + extraShown
})
const sortDateIso = computed(() => props.post.post_date || props.post.downloaded_at)
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
@@ -92,25 +101,8 @@ const relativeDate = computed(() => {
return new Date(sortDateIso.value).toLocaleDateString()
})
const descriptionToShow = computed(() => {
if (expanded.value && fullDescription.value) return fullDescription.value
return props.post.description_plain
})
async function expand() {
if (!fullDescription.value) {
const detail = await postsStore.getPostFull(props.post.id)
fullDescription.value = detail?.description_full || props.post.description_plain
}
expanded.value = true
}
function formatBytes(n) {
if (!n) return '0 B'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
function onCardClick () {
postModal.open(props.post)
}
</script>
@@ -118,13 +110,25 @@ function formatBytes(n) {
.fc-post-card {
padding: 1rem;
margin-bottom: 1rem;
cursor: pointer;
container-type: inline-size;
transition: border-color 0.15s ease;
}
.fc-post-card:hover {
border-color: rgb(var(--v-theme-accent));
}
.fc-post-card:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 2px;
}
.fc-post-card__head {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 12px;
}
.fc-post-card__artist {
color: rgb(var(--v-theme-on-surface));
@@ -133,72 +137,118 @@ function formatBytes(n) {
}
.fc-post-card__artist:hover { color: rgb(var(--v-theme-accent)); }
.fc-post-card__date { white-space: nowrap; }
.fc-post-card__title {
font-size: 1.05rem;
margin: 0.6rem 0 0.4rem;
}
.fc-post-card__desc {
white-space: pre-wrap;
font-size: 0.92rem;
color: rgb(var(--v-theme-on-surface));
margin-bottom: 0.6rem;
}
.fc-post-card__more {
background: none;
border: none;
color: rgb(var(--v-theme-accent));
cursor: pointer;
padding: 0 0.25rem;
font-size: inherit;
}
.fc-post-card__thumbs {
.fc-post-card__body {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
margin: 0.5rem 0;
flex-direction: column;
gap: 16px;
}
.fc-post-card__thumb { line-height: 0; }
.fc-post-card__thumb-img {
border-radius: 4px;
@container (min-width: 800px) {
.fc-post-card__body {
flex-direction: row;
gap: 24px;
}
.fc-post-card__media {
flex: 0 0 50%;
}
.fc-post-card__text {
flex: 1 1 0;
min-width: 0;
}
}
.fc-post-card__hero {
width: 100%;
aspect-ratio: 16 / 10;
overflow: hidden;
border-radius: 6px;
}
.fc-post-card__more-thumbs {
display: inline-flex;
.fc-post-card__hero img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.fc-post-card__rail {
display: flex;
gap: 6px;
margin-top: 6px;
}
.fc-post-card__rail-cell {
width: 80px;
height: 80px;
overflow: hidden;
border-radius: 4px;
}
.fc-post-card__rail-cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.fc-post-card__rail-more {
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 96px;
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
border-radius: 4px;
color: rgb(var(--v-theme-on-surface-variant));
text-decoration: none;
font-size: 0.85rem;
}
.fc-post-card__more-thumbs:hover {
color: rgb(var(--v-theme-accent));
border-color: rgb(var(--v-theme-accent));
.fc-post-card__title {
font-family: 'Fraunces', Georgia, serif;
font-size: 18px;
font-weight: 500;
margin: 0 0 8px 0;
color: rgb(var(--v-theme-on-surface));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.fc-post-card__attachments {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.6rem;
.fc-post-card__title--missing {
font-style: italic;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__att {
@container (min-width: 800px) {
.fc-post-card__title {
font-size: 20px;
-webkit-line-clamp: 1;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.fc-post-card__desc {
font-size: 0.9rem;
line-height: 1.5;
color: rgb(var(--v-theme-on-surface));
margin: 0 0 12px 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.fc-post-card__desc--missing {
font-style: italic;
color: rgb(var(--v-theme-on-surface-variant));
}
@container (min-width: 800px) {
.fc-post-card__desc {
-webkit-line-clamp: 5;
}
}
.fc-post-card__atts {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.5rem;
border: 1px solid rgb(var(--v-theme-on-surface-variant));
border-radius: 999px;
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
font-size: 0.82rem;
gap: 6px;
font-size: 0.85rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__att:hover {
color: rgb(var(--v-theme-accent));
border-color: rgb(var(--v-theme-accent));
}
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
</style>
@@ -0,0 +1,35 @@
<template>
<div class="fc-post-empty">
<v-icon size="48" class="fc-post-empty__icon">mdi-image-off-outline</v-icon>
<div class="fc-post-empty__text">No images attached to this post</div>
</div>
</template>
<script setup>
// No props — pure presentational placeholder.
</script>
<style scoped>
.fc-post-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px 16px;
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
border-radius: 8px;
background: rgba(20, 23, 26, 0.3);
width: 100%;
height: 100%;
min-height: 200px;
}
.fc-post-empty__icon {
color: rgb(var(--v-theme-on-surface-variant));
opacity: 0.6;
}
.fc-post-empty__text {
color: rgb(var(--v-theme-on-surface-variant));
font-size: 0.9rem;
}
</style>
@@ -0,0 +1,63 @@
<template>
<div class="fc-post-grid">
<button
v-for="(t, idx) in thumbnails"
:key="t.image_id"
type="button"
class="fc-post-grid__cell"
:aria-label="`Open image ${idx + 1} of ${thumbnails.length}`"
@click="openImage(t.image_id, idx)"
>
<img
:src="t.thumbnail_url"
:alt="`thumbnail ${idx + 1}`"
loading="lazy"
/>
</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useModalStore } from '../../stores/modal.js'
const props = defineProps({
thumbnails: { type: Array, required: true }, // [{ image_id, thumbnail_url, ... }]
})
const modal = useModalStore()
const imageIds = computed(() => props.thumbnails.map(t => t.image_id))
function openImage (id, idx) {
modal.open(id, { postImageIds: imageIds.value, initialIndex: idx })
}
</script>
<style scoped>
.fc-post-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 6px;
}
.fc-post-grid__cell {
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 4px;
cursor: pointer;
border: 0;
padding: 0;
background: rgb(var(--v-theme-background));
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.fc-post-grid__cell:hover {
transform: scale(1.02);
box-shadow: 0 0 0 2px rgb(var(--v-theme-accent));
}
.fc-post-grid__cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<template>
<v-dialog
:model-value="store.isOpen"
max-width="1100"
scrollable
@update:model-value="(v) => { if (!v) close() }"
>
<v-card v-if="store.currentPost" class="fc-post-modal">
<div class="fc-post-modal__head">
<v-chip size="x-small" variant="tonal">{{ post.source.platform }}</v-chip>
<RouterLink
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
class="fc-post-modal__artist"
@click="close"
>{{ post.artist.name }}</RouterLink>
<span class="fc-post-modal__date" :title="absoluteDate">
{{ relativeDate }}
</span>
<span v-if="post.thumbnails?.length" class="fc-post-modal__meta">
· {{ post.thumbnails.length }} image{{ post.thumbnails.length === 1 ? '' : 's' }}
</span>
<span v-if="post.attachments?.length" class="fc-post-modal__meta">
· {{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }}
</span>
<v-spacer />
<v-btn
v-if="post.post_url"
:href="post.post_url" target="_blank" rel="noopener"
icon="mdi-open-in-new" size="small" variant="text"
:aria-label="`open original post on ${post.source.platform}`"
/>
<v-btn
icon="mdi-close" size="small" variant="text"
aria-label="Close"
@click="close"
/>
</div>
<v-card-text class="fc-post-modal__body">
<section v-if="post.thumbnails?.length" class="fc-post-modal__sec">
<PostImageGrid :thumbnails="post.thumbnails" />
<div v-if="!store.detailLoaded" class="fc-post-modal__loading-hint">
Loading full image list…
</div>
</section>
<section v-if="post.post_title" class="fc-post-modal__sec">
<h2 class="fc-post-modal__title">{{ post.post_title }}</h2>
</section>
<section v-if="descriptionHtml" class="fc-post-modal__sec">
<div class="fc-post-modal__desc" v-html="descriptionHtml" />
</section>
<section v-if="post.attachments?.length" class="fc-post-modal__sec">
<h3 class="fc-post-modal__h3">Attachments</h3>
<div class="fc-post-modal__atts">
<a
v-for="att in post.attachments" :key="att.id"
:href="att.download_url" download
class="fc-post-modal__att"
>
<v-icon size="small" class="fc-post-modal__att-icon">mdi-paperclip</v-icon>
<span class="fc-post-modal__att-name">{{ att.original_filename }}</span>
<span class="fc-post-modal__att-size">({{ formatBytes(att.size_bytes) }})</span>
</a>
</div>
</section>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { usePostModalStore } from '../../stores/postModal.js'
import { sanitizeHtml } from '../../utils/htmlSanitize.js'
import PostImageGrid from './PostImageGrid.vue'
const store = usePostModalStore()
const post = computed(() => store.currentPost || {})
const sortDateIso = computed(() => post.value.post_date || post.value.downloaded_at)
const absoluteDate = computed(() => sortDateIso.value
? new Date(sortDateIso.value).toLocaleString()
: '')
const relativeDate = computed(() => {
if (!sortDateIso.value) return ''
const then = new Date(sortDateIso.value).getTime()
const diff = (Date.now() - then) / 1000
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 86400 * 30) return `${Math.floor(diff / 86400)}d ago`
return new Date(sortDateIso.value).toLocaleDateString()
})
const descriptionHtml = computed(() => {
// Detail endpoint returns description_full as plain text (the
// existing service uses html_to_plain on the stored description).
// Render the plain text in <p> wrappers; the sanitizer below is
// defensive for the case where the backend ever switches to raw HTML.
const raw = post.value.description_full || post.value.description_plain
if (!raw) return ''
if (/[<>]/.test(raw)) return sanitizeHtml(raw)
const esc = raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
return esc
.split(/\n\s*\n/)
.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
})
function close () {
store.close()
}
function formatBytes (n) {
if (!n) return '0 B'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
}
</script>
<style scoped>
.fc-post-modal {
background: rgb(var(--v-theme-surface));
max-height: 90vh;
display: flex;
flex-direction: column;
}
.fc-post-modal__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
font-size: 0.85rem;
color: rgb(var(--v-theme-on-surface-variant));
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.15);
}
.fc-post-modal__artist {
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
font-weight: 600;
}
.fc-post-modal__artist:hover { color: rgb(var(--v-theme-accent)); }
.fc-post-modal__date { white-space: nowrap; }
.fc-post-modal__meta { white-space: nowrap; }
.fc-post-modal__body {
padding: 16px;
overflow-y: auto;
flex: 1 1 auto;
}
.fc-post-modal__sec {
margin-bottom: 24px;
}
.fc-post-modal__sec:last-child { margin-bottom: 0; }
.fc-post-modal__title {
font-family: 'Fraunces', Georgia, serif;
font-size: 24px;
font-weight: 500;
margin: 0;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-modal__h3 {
font-family: 'Fraunces', Georgia, serif;
font-size: 16px;
font-weight: 500;
margin: 0 0 8px 0;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-modal__desc {
font-size: 0.95rem;
line-height: 1.55;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-modal__desc :deep(p) { margin: 0 0 12px 0; }
.fc-post-modal__desc :deep(a) { color: rgb(var(--v-theme-accent)); }
.fc-post-modal__loading-hint {
margin-top: 8px;
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-modal__atts {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.fc-post-modal__att {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid rgb(var(--v-theme-on-surface-variant));
border-radius: 999px;
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
font-size: 0.85rem;
}
.fc-post-modal__att:hover {
color: rgb(var(--v-theme-accent));
border-color: rgb(var(--v-theme-accent));
}
.fc-post-modal__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
</style>
+66 -21
View File
@@ -6,14 +6,31 @@ export const useModalStore = defineStore('modal', () => {
const api = useApi()
const currentImageId = ref(null)
const current = ref(null) // full image detail from API
const current = ref(null)
const loading = ref(false)
const error = ref(null)
async function open(id) {
// Post-scoped cycle. When set, prev/next cycles within this array
// (used by PostModal's PostImageGrid clicks). When null, prev/next
// falls back to current.value.neighbors (the gallery-store-driven
// /api/gallery/image/<id> neighbors).
const postImageIds = ref(null)
const postImageIndex = ref(0)
async function open (id, opts = {}) {
currentImageId.value = id
loading.value = true
error.value = null
// Update post-scoped state if caller passed it; otherwise clear so
// the next open() from gallery context uses neighbors mode.
if (opts.postImageIds != null) {
postImageIds.value = opts.postImageIds
postImageIndex.value = opts.postImageIds.indexOf(id)
if (postImageIndex.value < 0) postImageIndex.value = 0
} else if (opts.clearPostScope !== false && postImageIds.value != null) {
postImageIds.value = null
postImageIndex.value = 0
}
try {
current.value = await api.get(`/api/gallery/image/${id}`)
} catch (e) {
@@ -24,41 +41,58 @@ export const useModalStore = defineStore('modal', () => {
}
}
async function close() {
async function close () {
currentImageId.value = null
current.value = null
error.value = null
postImageIds.value = null
postImageIndex.value = 0
}
async function goPrev() {
if (current.value && current.value.neighbors.prev_id) {
async function goPrev () {
if (postImageIds.value != null) {
if (postImageIndex.value > 0) {
const newIdx = postImageIndex.value - 1
const newId = postImageIds.value[newIdx]
postImageIndex.value = newIdx
await open(newId, { postImageIds: postImageIds.value })
}
return
}
if (current.value && current.value.neighbors?.prev_id) {
await open(current.value.neighbors.prev_id)
}
}
async function goNext() {
if (current.value && current.value.neighbors.next_id) {
async function goNext () {
if (postImageIds.value != null) {
if (postImageIndex.value < postImageIds.value.length - 1) {
const newIdx = postImageIndex.value + 1
const newId = postImageIds.value[newIdx]
postImageIndex.value = newIdx
await open(newId, { postImageIds: postImageIds.value })
}
return
}
if (current.value && current.value.neighbors?.next_id) {
await open(current.value.neighbors.next_id)
}
}
async function reloadTags() {
async function reloadTags () {
if (!currentImageId.value) return
const tags = await api.get(`/api/images/${currentImageId.value}/tags`)
if (current.value) current.value.tags = tags
}
async function removeTag(tagId) {
async function removeTag (tagId) {
if (!currentImageId.value) return
// Optimistic UI: remove locally first, restore on error.
const prev = current.value.tags
current.value.tags = current.value.tags.filter(t => t.id !== tagId)
try {
// FC-2b: removal also records a per-image rejection (suggestions/dismiss
// is the rejection-recording endpoint) so the allowlist maintenance
// task won't re-apply this tag to this image.
await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`)
await api.post(`/api/images/${currentImageId.value}/suggestions/dismiss`, {
body: { tag_id: tagId }
body: { tag_id: tagId },
})
} catch (e) {
current.value.tags = prev
@@ -67,25 +101,36 @@ export const useModalStore = defineStore('modal', () => {
}
}
async function addExistingTag(tagId) {
async function addExistingTag (tagId) {
if (!currentImageId.value) return
await api.post(`/api/images/${currentImageId.value}/tags`, {
body: { tag_id: tagId, source: 'manual' }
body: { tag_id: tagId, source: 'manual' },
})
await reloadTags()
}
async function createAndAdd({ name, kind, fandom_id = null }) {
async function createAndAdd ({ name, kind, fandom_id = null }) {
const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } })
await addExistingTag(tag.id)
}
const isOpen = computed(() => currentImageId.value !== null)
const canPrev = computed(() => current.value?.neighbors?.prev_id != null)
const canNext = computed(() => current.value?.neighbors?.next_id != null)
const canPrev = computed(() => {
if (postImageIds.value != null) return postImageIndex.value > 0
return current.value?.neighbors?.prev_id != null
})
const canNext = computed(() => {
if (postImageIds.value != null) {
return postImageIndex.value < (postImageIds.value.length - 1)
}
return current.value?.neighbors?.next_id != null
})
return {
currentImageId, current, loading, error, isOpen, canPrev, canNext,
open, close, goPrev, goNext, reloadTags, removeTag, addExistingTag, createAndAdd
currentImageId, current, loading, error,
postImageIds, postImageIndex,
isOpen, canPrev, canNext,
open, close, goPrev, goNext,
reloadTags, removeTag, addExistingTag, createAndAdd,
}
})
+39
View File
@@ -0,0 +1,39 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { usePostsStore } from './posts.js'
export const usePostModalStore = defineStore('postModal', () => {
const postsStore = usePostsStore()
// Feed-shape on open; upgraded to detail shape (full description +
// uncapped thumbnails) once getPostFull resolves.
const currentPost = ref(null)
const detailLoaded = ref(false)
const error = ref(null)
const isOpen = computed(() => currentPost.value !== null)
async function open (post) {
currentPost.value = post
detailLoaded.value = false
error.value = null
try {
const detail = await postsStore.getPostFull(post.id)
if (currentPost.value?.id === post.id) {
currentPost.value = detail
detailLoaded.value = true
}
} catch (e) {
// Keep feed-shape data; PostModal can still render title + truncated
// description + the up-to-6 feed thumbnails. Just surface the error.
error.value = e.message
}
}
function close () {
currentPost.value = null
detailLoaded.value = false
error.value = null
}
return { currentPost, detailLoaded, error, isOpen, open, close }
})
+5 -7
View File
@@ -2,24 +2,22 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
// `artist` retired in FC-2d-vii-c (provenance is its own axis), `meta` +
// `rating` retired by operator 2026-05-26 (alembic 0023 drops them from
// the enum). KIND_COLOR keeps `archive` + `post` so any legacy
// system-managed tag still renders with a neutral color.
const KIND_OPTIONS = [
{ value: 'general', label: 'General', icon: 'mdi-tag' },
{ value: 'artist', label: 'Artist', icon: 'mdi-palette' },
{ value: 'character', label: 'Character', icon: 'mdi-account-circle' },
{ value: 'fandom', label: 'Fandom', icon: 'mdi-book-open-page-variant' },
{ value: 'series', label: 'Series', icon: 'mdi-bookshelf' },
{ value: 'meta', label: 'Meta', icon: 'mdi-cog-outline' },
{ value: 'rating', label: 'Rating', icon: 'mdi-shield-check-outline' }
{ value: 'series', label: 'Series', icon: 'mdi-bookshelf' }
]
const KIND_COLOR = {
artist: 'accent',
character: 'info',
fandom: 'secondary',
series: 'warning',
general: 'on-surface',
meta: 'on-surface',
rating: 'on-surface',
archive: 'on-surface',
post: 'on-surface'
}
+61
View File
@@ -0,0 +1,61 @@
// Whitelist-based HTML sanitizer for rendering third-party post
// descriptions (e.g. Patreon) via v-html. Strips script/style/iframe
// + event handlers + dangerous hrefs. Tag whitelist covers what
// Patreon, SubscribeStar, and similar platforms ship in normal posts.
//
// Not a substitute for server-side sanitization in higher-stakes
// contexts. This is for FC's single-operator homelab posture where
// the content source is the operator's own subscriptions.
const ALLOWED_TAGS = new Set([
'a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li',
'ol', 'p', 'pre', 's', 'span', 'strong', 'sub', 'sup', 'u', 'ul',
])
const ALLOWED_ATTRS = {
a: new Set(['href', 'title', 'rel', 'target']),
img: new Set(['src', 'alt', 'title', 'width', 'height']),
// any tag → these always allowed
'*': new Set(['class']),
}
const SAFE_URL_RE = /^(https?:|mailto:|#|\/)/i
export function sanitizeHtml (html) {
if (typeof html !== 'string' || !html) return ''
const doc = new DOMParser().parseFromString(html, 'text/html')
_scrubNode(doc.body)
return doc.body.innerHTML
}
function _scrubNode (node) {
const children = Array.from(node.children)
for (const child of children) {
const tag = child.tagName.toLowerCase()
if (!ALLOWED_TAGS.has(tag)) {
// Strip the tag but keep its text content as a fallback.
const text = document.createTextNode(child.textContent || '')
child.replaceWith(text)
continue
}
const allowed = new Set([
...(ALLOWED_ATTRS[tag] || []),
...(ALLOWED_ATTRS['*'] || []),
])
for (const attr of Array.from(child.attributes)) {
const name = attr.name.toLowerCase()
if (name.startsWith('on') || !allowed.has(name)) {
child.removeAttribute(attr.name)
continue
}
if ((name === 'href' || name === 'src') && !SAFE_URL_RE.test(attr.value)) {
child.removeAttribute(attr.name)
}
}
if (tag === 'a' && child.getAttribute('target') === '_blank') {
child.setAttribute('rel', 'noopener noreferrer')
}
_scrubNode(child)
}
}
+3 -3
View File
@@ -3,11 +3,11 @@
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<v-container v-else-if="store.notFound" class="py-6">
<v-container v-else-if="store.notFound" class="pt-2 pb-6">
<v-alert type="warning" variant="tonal">Artist not found.</v-alert>
</v-container>
<v-container v-else-if="store.error && !store.overview" class="py-6">
<v-container v-else-if="store.error && !store.overview" class="pt-2 pb-6">
<v-alert type="error" variant="tonal" closable>{{ store.error }}</v-alert>
</v-container>
@@ -19,7 +19,7 @@
:post-count="store.postCount"
:last-added="store.lastAdded"
/>
<v-container fluid class="py-4">
<v-container fluid class="pt-2 pb-4">
<v-window v-model="tab">
<v-window-item value="posts">
<ArtistPostsTab
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<div class="fc-artists__controls">
<v-text-field
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<ExtensionKeyBar />
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<FilterPills v-model="pill" />
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<Teleport to="#fc-nav-actions">
<v-btn
:color="sel.isSelectMode ? 'accent' : undefined"
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container class="py-8">
<v-container class="pt-3 pb-8">
<h1 class="fc-h1 mb-4">{{ title }}</h1>
<v-alert type="info" variant="tonal" icon="mdi-toolbox">
This surface is a placeholder. It will be implemented in
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container class="py-6" max-width="900">
<v-container class="pt-2 pb-6" max-width="900">
<PostsFilterBar
:artist-id="artistFilter"
:platform="platformFilter"
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<div class="fc-series__head">
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
<span class="fc-series__count">{{ store.pages.length }} page(s)</span>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<!-- Sticky tabs: operator-flagged 2026-05-25 long Import / Maintenance
panels pushed the tab strip out of the viewport, forcing a scroll-
to-top just to change tab. AppShell's TopNav is 64px sticky, so the
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<Teleport to="#fc-nav-actions">
<v-btn
prepend-icon="mdi-shuffle-variant" variant="tonal" color="accent"
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<div class="fc-subs__bar">
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
Add subscription
+6 -5
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<div class="fc-tags__controls">
<v-text-field
@@ -99,10 +99,11 @@ import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
// Must stay a subset of the backend TagKind enum (character, fandom,
// general, series, archive, post, meta, rating). 'fandom' is this
// model's copyright/franchise concept (characters link via fandom_id).
// 'artist' retired in FC-2d-vii-c — artists are the Artist row, not a tag.
const KINDS = ['character', 'fandom', 'general', 'series', 'meta']
// general, series, archive, post). 'fandom' is this model's
// copyright/franchise concept (characters link via fandom_id).
// 'artist' retired in FC-2d-vii-c — artists are the Artist row, not
// a tag. 'meta' + 'rating' retired by operator 2026-05-26 (alembic 0023).
const KINDS = ['character', 'fandom', 'general', 'series']
const store = useTagDirectoryStore()
const router = useRouter()
+47
View File
@@ -117,3 +117,50 @@ async def test_detail_404_for_unknown(client):
assert resp.status_code == 404
body = await resp.get_json()
assert body["error"] == "not_found"
@pytest.mark.asyncio
async def test_detail_returns_uncapped_thumbnails(client, db):
"""Feed query caps thumbnails at 6 for previews; detail endpoint
returns the full list so PostModal can render the masonry grid."""
from backend.app.models import ImageRecord
a = Artist(name="yuki-api", slug="yuki-api")
db.add(a)
await db.flush()
s = Source(
artist_id=a.id, platform="patreon",
url="https://patreon.com/cw/yuki-api", enabled=True,
)
db.add(s)
await db.flush()
p = Post(
source_id=s.id, external_post_id="DETAIL10",
post_title="big post", description="<p>body</p>",
)
db.add(p)
await db.flush()
# Seed 10 ImageRecord rows linked to this post via primary_post_id.
for i in range(10):
sha = f"y{i:x}".ljust(64, "0")[:64]
rec = ImageRecord(
path=f"/images/test-yuki-{i}.jpg",
sha256=sha,
size_bytes=1,
mime="image/jpeg",
width=64,
height=64,
origin="downloaded",
integrity_status="unknown",
primary_post_id=p.id,
artist_id=a.id,
)
db.add(rec)
await db.commit()
resp = await client.get(f"/api/posts/{p.id}")
assert resp.status_code == 200
body = await resp.get_json()
# Detail returns ALL 10 thumbnails (feed would return 6 + thumbnails_more).
assert len(body["thumbnails"]) == 10
assert body["description_full"] == "body"
+48 -2
View File
@@ -55,6 +55,52 @@ async def test_create_character_with_bad_fandom_id(client):
@pytest.mark.asyncio
async def test_create_tag_missing_required(client):
resp = await client.post("/api/tags", json={"name": "Bob"})
async def test_create_tag_missing_name_400(client):
"""name is still required; only `kind` became optional."""
resp = await client.post("/api/tags", json={})
assert resp.status_code == 400
resp = await client.post("/api/tags", json={"kind": "artist"})
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_create_tag_name_only_defaults_to_general(client):
"""IR-style: name without kind and without `kind:` prefix → general."""
resp = await client.post("/api/tags", json={"name": "sunset"})
assert resp.status_code == 201
body = await resp.get_json()
assert body["name"] == "sunset"
assert body["kind"] == "general"
@pytest.mark.asyncio
async def test_create_tag_with_kind_prefix(client):
"""IR-style: `name="character:Saber"` without explicit kind → parsed as character."""
resp = await client.post("/api/tags", json={"name": "character:Saber"})
assert resp.status_code == 201
body = await resp.get_json()
assert body["name"] == "Saber"
assert body["kind"] == "character"
@pytest.mark.asyncio
async def test_explicit_kind_overrides_prefix_parsing(client):
"""If caller passes explicit kind, don't re-parse the name —
colon and prefix stay literal."""
resp = await client.post(
"/api/tags", json={"name": "character:Saber", "kind": "general"}
)
assert resp.status_code == 201
body = await resp.get_json()
assert body["name"] == "character:Saber"
assert body["kind"] == "general"
@pytest.mark.asyncio
async def test_unknown_prefix_kept_literal(client):
"""`http:example` — `http` not in KNOWN_KINDS → kind=general, literal name."""
resp = await client.post("/api/tags", json={"name": "http://example.com"})
assert resp.status_code == 201
body = await resp.get_json()
assert body["name"] == "http://example.com"
assert body["kind"] == "general"
+86
View File
@@ -0,0 +1,86 @@
"""CORS preflight + response headers for moz-extension:// + chrome-extension://.
Operator-flagged 2026-05-26: extension's first 'Test connection' tap
returned `NetworkError` because /api/credentials had no OPTIONS handler
and no Access-Control-Allow-Origin response header. Browser preflight
failed → fetch blocked.
These tests pin the contract: any request from a moz-extension:// or
chrome-extension:// origin gets the right ACL headers. Plain browser
requests (no Origin header, or a regular https:// Origin) get nothing
— we don't want to open CORS up generally.
"""
import pytest
from backend.app import create_app
pytestmark = pytest.mark.integration
@pytest.fixture
async def client():
app = create_app()
async with app.test_client() as c:
yield c
@pytest.mark.asyncio
async def test_extension_preflight_returns_204_with_acl_headers(client):
resp = await client.options(
"/api/credentials",
headers={
"Origin": "moz-extension://abcd1234-uuid-fake",
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "X-Extension-Key",
},
)
assert resp.status_code == 204
assert resp.headers["Access-Control-Allow-Origin"] == "moz-extension://abcd1234-uuid-fake"
assert "OPTIONS" in resp.headers["Access-Control-Allow-Methods"]
assert "X-Extension-Key" in resp.headers["Access-Control-Allow-Headers"]
@pytest.mark.asyncio
async def test_extension_get_carries_acl_headers(client):
# The actual response (post-preflight) also needs ACL headers — the
# browser checks them again before exposing the response body.
resp = await client.get(
"/api/credentials",
headers={"Origin": "moz-extension://abcd1234-uuid-fake"},
)
# 200 with empty list (no creds seeded) — what matters here is the
# CORS header is present.
assert resp.headers["Access-Control-Allow-Origin"] == "moz-extension://abcd1234-uuid-fake"
@pytest.mark.asyncio
async def test_chrome_extension_origin_also_allowed(client):
resp = await client.options(
"/api/credentials",
headers={
"Origin": "chrome-extension://abcd1234-uuid-fake",
"Access-Control-Request-Method": "POST",
},
)
assert resp.status_code == 204
assert resp.headers["Access-Control-Allow-Origin"] == "chrome-extension://abcd1234-uuid-fake"
@pytest.mark.asyncio
async def test_normal_browser_request_gets_no_cors_headers(client):
# A regular browser tab (https://example.com / file:// / no Origin
# at all) should NOT get any Access-Control-Allow-* — the extension
# whitelist is intentionally narrow.
resp = await client.get(
"/api/credentials",
headers={"Origin": "https://evil.example.com"},
)
assert "Access-Control-Allow-Origin" not in resp.headers
@pytest.mark.asyncio
async def test_no_origin_header_unaffected(client):
# Same-origin requests (no Origin header) — unaffected.
resp = await client.get("/api/credentials")
assert "Access-Control-Allow-Origin" not in resp.headers
+4 -2
View File
@@ -25,6 +25,10 @@ def test_tag_has_kind_and_fandom_id():
def test_tag_kind_enum_values():
# Current TagKind enum after alembic 0023 dropped meta + rating
# (operator-retired 2026-05-26). `artist` is still in the enum
# for backward-compat with historical rows, though new artist
# tags don't get created (Artist row is canonical per FC-2d-vii-c).
expected = {
"artist",
"character",
@@ -33,8 +37,6 @@ def test_tag_kind_enum_values():
"series",
"archive",
"post",
"meta",
"rating",
}
assert {k.value for k in TagKind} == expected
+54
View File
@@ -0,0 +1,54 @@
"""parse_kind_prefix — IR-style `kind:name` shortcut at the input boundary."""
from backend.app.utils.tag_prefix import KNOWN_KINDS, parse_kind_prefix
def test_recognized_kinds_match_user_input_set():
# Excluded: archive + post (system-managed), general (default for
# un-prefixed input), artist (retired in FC-2d-vii-c — first-class
# entities now), meta + rating (retired as user-typeable per
# operator 2026-05-26).
assert KNOWN_KINDS == frozenset({
"character", "fandom", "series",
})
def test_character_prefix_parsed():
assert parse_kind_prefix("character:Saber") == ("character", "Saber")
def test_case_insensitive_prefix():
assert parse_kind_prefix("Character:Saber") == ("character", "Saber")
assert parse_kind_prefix("CHARACTER:Saber") == ("character", "Saber")
def test_no_prefix_returns_none_kind():
assert parse_kind_prefix("sunset") == (None, "sunset")
def test_unknown_prefix_kept_as_literal():
# 'http' is not a known kind — preserve the literal text.
assert parse_kind_prefix("http://example.com") == (None, "http://example.com")
def test_retired_prefixes_kept_as_literal():
# `artist:`, `meta:`, `rating:` are no longer recognized — they
# parse as literal text so the operator's input is preserved (and
# serves as a nudge to use the appropriate dedicated UI instead).
assert parse_kind_prefix("artist:Eric") == (None, "artist:Eric")
assert parse_kind_prefix("meta:wide") == (None, "meta:wide")
assert parse_kind_prefix("rating:safe") == (None, "rating:safe")
def test_whitespace_stripped():
assert parse_kind_prefix("series: Bleach ") == ("series", "Bleach")
assert parse_kind_prefix(" sunset ") == (None, "sunset")
def test_empty_string():
assert parse_kind_prefix("") == (None, "")
def test_just_colon():
# Empty prefix → "" not in KNOWN_KINDS → falls through to (None, "...")
assert parse_kind_prefix(":foo") == (None, ":foo")