Compare commits

..

19 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
23 changed files with 1094 additions and 216 deletions
@@ -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')",
)
+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()
@@ -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)
}
}
+5 -4
View File
@@ -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"
+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")