Compare commits

...
7 Commits
Author SHA1 Message Date
bvandeusen 2a8f7cd8b6 Merge pull request '#69 dev→main: release v26.06.04.0' from dev into main
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m2s
Build images / sign-extension (push) Has been skipped
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 6s
2026-06-04 23:16:12 -04:00
bvandeusenandClaude Opus 4.8 86efbf7f2c fix(modal): kebab menus open via explicit v-model, not activator click
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 17s
CI / integration (push) Successful in 2m56s
Operator-confirmed on a fresh build: both the tag-chip and suggestion
kebabs still never opened. The prior 8326e54 'fix' only wrapped them in a
<span @click.stop> — inert for SuggestionItem (no parent capture) — and
never addressed why the `#activator`/`v-bind="props"` click failed to
toggle the menu inside the teleported ImageViewer modal. The dialogs in
that same modal open via v-model and work, so drive the menus the same way:

- The activator (v-btn / v-icon) toggles a reactive flag with @click.stop
  (which also shields the chip's close button / any parent).
- The v-menu binds that flag (v-model / :model-value) and uses
  activator="parent" with :open-on-click="false" purely for positioning,
  so opening no longer depends on Vuetify's activator-click path.
- TagPanel tracks a single openTagId (one chip menu open at a time).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:01:55 -04:00
bvandeusenandClaude Opus 4.8 3a0cca5aca fix(tags): allow creating a character with no fandom
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 3m4s
Not all characters belong to a fandom (original characters, unsorted).
The create flow forced every new character through FandomPicker, whose
only outcomes were 'Use this fandom' (disabled until one is picked) or
Cancel (which aborts the whole creation) — there was no way to confirm a
character with no fandom.

- FandomPicker: add a 'No fandom' action that emits confirm(null).
- TagAutocomplete.onFandomChosen: pass fandom_id: null when null is
  emitted.

Backend already supported this end to end (Tag.fandom_id nullable, the
CHECK only forbids fandom_id on non-character kinds, tag_service
find_or_create defaults fandom_id=None, API reads body.get). A fandom can
still be assigned later from the chip kebab's 'Set fandom…'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:50:19 -04:00
bvandeusen 83f8af8090 Merge pull request 'dev→main: surface near-duplicate (pHash) control + reorder import tab' (#68) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 2m56s
2026-06-04 21:39:44 -04:00
bvandeusenandClaude Opus 4.8 a5b3702863 feat(settings): surface the near-duplicate (pHash) control + reorder import tab
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 10s
CI / integration (push) Successful in 3m6s
The phash_threshold knob (controls whether edits/variants of an image are
dropped as near-duplicates on import) was buried at the bottom of the import
filters form and labelled opaquely, so it read as 'missing'. Hoist it to the
TOP of the form as a 'Near-duplicate sensitivity' section: a labelled slider
(Exact / Strict / Default / Loose stops, 0-16) for the gist + the precise
number field, both bound to phash_threshold, with copy that says plainly to
lower it if variants are being dropped.

Also swap the import-tab order to filters → trigger → recent-tasks (filters on
top per operator); the task list stays directly under the trigger for hit/miss
feedback adjacency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:42:32 -04:00
bvandeusen 9a2617c1a2 Merge pull request 'dev→main: post-card redesign (images→modal, in-place text expand)' (#67) from dev into main
CI / lint (push) Failing after 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
CI / integration (push) Successful in 2m57s
2026-06-04 17:32:50 -04:00
bvandeusenandClaude Opus 4.8 509a7958cf feat(posts): images open the modal; only text expands in place
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 27s
CI / integration (push) Successful in 3m2s
Post cards no longer expand the whole card on click (the old two-click path
to the images). The card is compact-only now:
- Hero / rail thumbs / the +N tile are buttons that open the post-scoped image
  modal (modal.open(id, { postImageIds })) so you view big + arrow through ALL
  the post's images. The feed caps thumbnails at 6, so for posts with more we
  lazily getPostFull to get the complete id list; +N opens at the first hidden
  image.
- The description is the ONLY in-place expansion: a Show more / Show less toggle
  shown only when the text is actually truncated (server description_truncated
  flag OR a measured CSS-clamp overflow, ResizeObserver-guarded). Expanding
  loads description_full when server-truncated and renders it unclamped.
- Attachments: download chips now render inline in the compact card (the feed
  already carries download_url), since the expanded view is gone.

Removes PostImageGrid.vue (the mosaic, now unused). Tests cover show-more
visibility + image-click opening the scoped modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:29:19 -04:00
10 changed files with 316 additions and 328 deletions
@@ -17,6 +17,10 @@
</div> </div>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<!-- Not every character belongs to a fandom (original characters,
unsorted, etc.). "No fandom" creates the character unassigned;
a fandom can still be set later from the chip's kebab menu. -->
<v-btn variant="text" @click="onNoFandom">No fandom</v-btn>
<v-spacer /> <v-spacer />
<v-btn @click="$emit('cancel')">Cancel</v-btn> <v-btn @click="$emit('cancel')">Cancel</v-btn>
<v-btn :disabled="!selectedId" color="primary" rounded="pill" @click="onConfirm">Use this fandom</v-btn> <v-btn :disabled="!selectedId" color="primary" rounded="pill" @click="onConfirm">Use this fandom</v-btn>
@@ -45,4 +49,9 @@ function onConfirm() {
const f = store.fandomCache.find(x => x.id === selectedId.value) const f = store.fandomCache.find(x => x.id === selectedId.value)
if (f) emit('confirm', f) if (f) emit('confirm', f)
} }
// Create the character with no fandom. Emits null so the caller knows this
// was a deliberate "unassigned", not a cancel.
function onNoFandom() {
emit('confirm', null)
}
</script> </script>
@@ -19,24 +19,25 @@
> >
Accept Accept
</v-btn> </v-btn>
<!-- Operator-flagged 2026-06-02: the kebab menu wasn't opening. <!-- Operator-flagged 2026-06-04: the kebab still wasn't opening. The
Wrapping in a <span @click.stop> matches the TagPanel chip prior `#activator` + `v-bind="props"` path never toggled the menu
fix — even though there's no parent click capture here today, inside this teleported modal, while v-model-driven overlays (the
the wrap is harmless and keeps both kebabs on the same dialogs in this modal) work fine. So drive the menu explicitly:
pattern. Click bubbles from the v-btn opens menu via the button toggles `menuOpen` with @click.stop (also shields any
activator props bubble continues to span stopPropagation parent), and `activator="parent"` anchors the menu for positioning
halts it. --> only — `:open-on-click="false"` keeps Vuetify's activator-click out
<span class="fc-suggestion__menu-wrap" @click.stop> of it, so there's a single, reliable opener. -->
<v-menu> <span class="fc-suggestion__menu-wrap">
<template #activator="{ props }"> <v-btn
<v-btn class="fc-suggestion__menu"
class="fc-suggestion__menu" icon="mdi-dots-vertical" size="small"
icon="mdi-dots-vertical" size="small" variant="outlined" density="compact"
variant="outlined" density="compact" :aria-label="`More actions for ${suggestion.display_name}`"
:aria-label="`More actions for ${suggestion.display_name}`" @click.stop="menuOpen = !menuOpen"
v-bind="props" />
/> <v-menu
</template> v-model="menuOpen" activator="parent" :open-on-click="false"
>
<v-list density="compact"> <v-list density="compact">
<v-list-item @click="$emit('alias', suggestion)"> <v-list-item @click="$emit('alias', suggestion)">
<v-list-item-title>Treat as alias for…</v-list-item-title> <v-list-item-title>Treat as alias for…</v-list-item-title>
@@ -51,11 +52,12 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref } from 'vue'
const props = defineProps({ suggestion: { type: Object, required: true } }) const props = defineProps({ suggestion: { type: Object, required: true } })
defineEmits(['accept', 'alias', 'dismiss']) defineEmits(['accept', 'alias', 'dismiss'])
const menuOpen = ref(false)
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`) const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
</script> </script>
@@ -147,10 +147,14 @@ function onCreate () {
reset() reset()
} }
// fandom is null when the user picked "No fandom" — characters don't all
// belong to a fandom. The backend already accepts fandom_id: null for the
// character kind (tag.kind check + nullable fandom_id), and a fandom can be
// assigned later from the chip kebab's "Set fandom…".
function onFandomChosen (fandom) { function onFandomChosen (fandom) {
fandomDialog.value = false fandomDialog.value = false
emit('pick-new', { emit('pick-new', {
name: pendingNewName, kind: 'character', fandom_id: fandom.id, name: pendingNewName, kind: 'character', fandom_id: fandom ? fandom.id : null,
}) })
pendingNewName = null pendingNewName = null
reset() reset()
+22 -14
View File
@@ -10,20 +10,24 @@
> >
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon> <v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span v-if="tag.fandom_id"></span> {{ tag.name }}<span v-if="tag.fandom_id"></span>
<!-- Operator-flagged 2026-06-02: the previous activator had <!-- Operator-flagged 2026-06-04: the `#activator` + `v-bind` menu
`@click.stop` directly on the v-icon, which silently never opened inside this teleported modal. Drive it explicitly
overrode Vuetify's onClick from `v-bind="mp"` — the menu instead (same mechanism as the dialogs below, which work): the
never opened. Now the v-icon receives the activator icon toggles `openTagId` with @click.stop (shielding the chip's
onClick cleanly, and the wrapping span absorbs the close button), and `activator="parent"` + `:open-on-click=false`
bubbled click so the chip's close button isn't tripped. --> anchors the menu for positioning only. One tag's menu open at a
<span class="kebab-wrap" @click.stop> time, so a single id is enough. -->
<v-menu> <span class="kebab-wrap">
<template #activator="{ props: mp }"> <v-icon
<v-icon size="x-small" class="ml-1 kebab-icon"
v-bind="mp" size="x-small" class="ml-1" icon="mdi-dots-vertical"
icon="mdi-dots-vertical" @click.stop="openTagId = openTagId === tag.id ? null : tag.id"
/> />
</template> <v-menu
:model-value="openTagId === tag.id"
activator="parent" :open-on-click="false"
@update:model-value="v => { if (!v) openTagId = null }"
>
<v-list density="compact"> <v-list density="compact">
<v-list-item @click="openRename(tag)"> <v-list-item @click="openRename(tag)">
<v-list-item-title>Rename…</v-list-item-title> <v-list-item-title>Rename…</v-list-item-title>
@@ -84,6 +88,9 @@ import FandomSetDialog from './FandomSetDialog.vue'
const modal = useModalStore() const modal = useModalStore()
const store = useTagStore() const store = useTagStore()
const errorMsg = ref(null) const errorMsg = ref(null)
// Which tag chip's kebab menu is open (only one at a time). Drives each
// chip menu's v-model so opening never depends on Vuetify's activator click.
const openTagId = ref(null)
const KIND_ICONS = { const KIND_ICONS = {
general: 'mdi-tag', character: 'mdi-account-circle', general: 'mdi-tag', character: 'mdi-account-circle',
@@ -147,4 +154,5 @@ async function onFandomUpdated() {
} }
.fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; } .fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; }
.kebab-wrap { display: inline-flex; align-items: center; } .kebab-wrap { display: inline-flex; align-items: center; }
.kebab-icon { cursor: pointer; }
</style> </style>
+156 -202
View File
@@ -1,29 +1,18 @@
<template> <template>
<v-card <v-card class="fc-post-card" variant="outlined">
:class="['fc-post-card', expanded && 'fc-post-card--expanded']"
variant="outlined"
:tabindex="expanded ? -1 : 0"
@click="onCardClick"
@keydown.enter="onCardClick"
>
<div class="fc-post-card__head"> <div class="fc-post-card__head">
<!-- Posts with no live subscription have source=null (alembic <!-- Posts with no live subscription have source=null (alembic 0030);
0030); show a "filesystem import" affordance instead of a show a "filesystem import" affordance instead of a platform chip. -->
platform chip. -->
<v-chip size="x-small" variant="tonal"> <v-chip size="x-small" variant="tonal">
{{ post.source?.platform ?? 'filesystem import' }} {{ post.source?.platform ?? 'filesystem import' }}
</v-chip> </v-chip>
<RouterLink <RouterLink
:to="{ name: 'artist', params: { slug: post.artist.slug } }" :to="{ name: 'artist', params: { slug: post.artist.slug } }"
class="fc-post-card__artist" class="fc-post-card__artist"
@click.stop
>{{ post.artist.name }}</RouterLink> >{{ post.artist.name }}</RouterLink>
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span> <span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
<span v-if="expanded && images.length" class="fc-post-card__meta"> <span v-if="totalImages" class="fc-post-card__meta">
· {{ images.length }} image{{ images.length === 1 ? '' : 's' }} · {{ totalImages }} image{{ totalImages === 1 ? '' : 's' }}
</span>
<span v-if="expanded && attachments.length" class="fc-post-card__meta">
· {{ attachments.length }} attachment{{ attachments.length === 1 ? '' : 's' }}
</span> </span>
<v-spacer /> <v-spacer />
<v-btn <v-btn
@@ -31,140 +20,108 @@
:href="post.post_url" target="_blank" rel="noopener" :href="post.post_url" target="_blank" rel="noopener"
icon="mdi-open-in-new" size="x-small" variant="text" icon="mdi-open-in-new" size="x-small" variant="text"
:aria-label="`open original post on ${post.source?.platform ?? 'web'}`" :aria-label="`open original post on ${post.source?.platform ?? 'web'}`"
@click.stop
/>
<v-btn
:icon="expanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
size="x-small" variant="text"
:aria-label="expanded ? 'Collapse post' : 'Expand post'"
@click.stop="toggleExpanded"
/> />
</div> </div>
<!-- Compact body: collapsed card. Hero + thumb rail + truncated text. --> <div class="fc-post-card__body">
<div v-if="!expanded" class="fc-post-card__body">
<div class="fc-post-card__media"> <div class="fc-post-card__media">
<template v-if="images.length"> <template v-if="images.length">
<div class="fc-post-card__hero"> <!-- Images open the post-scoped image modal (look bigger + arrow
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" /> through ALL the post's images) the card never expands. -->
</div> <button
<div v-if="rail.length" class="fc-post-card__rail"> type="button" class="fc-post-card__hero"
<div v-for="t in rail" :key="t.image_id" class="fc-post-card__rail-cell"> aria-label="Open images" @click="openModal(hero.image_id)"
<img :src="t.thumbnail_url" :alt="`thumbnail`" loading="lazy" /> >
</div> <img :src="hero.thumbnail_url" alt="hero thumbnail" loading="lazy" />
<div v-if="moreCount > 0" class="fc-post-card__rail-more"> </button>
+{{ moreCount }} <div v-if="rail.length || moreCount" class="fc-post-card__rail">
</div> <button
v-for="t in rail" :key="t.image_id" type="button"
class="fc-post-card__rail-cell"
aria-label="Open image" @click="openModal(t.image_id)"
>
<img :src="t.thumbnail_url" alt="thumbnail" loading="lazy" />
</button>
<button
v-if="moreCount > 0" type="button"
class="fc-post-card__rail-more"
:aria-label="`Open ${moreCount} more images`"
@click="openModalAtMore"
>+{{ moreCount }}</button>
</div> </div>
</template> </template>
<PostEmptyThumbs v-else /> <PostEmptyThumbs v-else />
</div> </div>
<div class="fc-post-card__text"> <div class="fc-post-card__text">
<h3 v-if="plainTitle" class="fc-post-card__title"> <h3 v-if="plainTitle" class="fc-post-card__title">{{ plainTitle }}</h3>
{{ plainTitle }}
</h3>
<h3 v-else class="fc-post-card__title fc-post-card__title--missing"> <h3 v-else class="fc-post-card__title fc-post-card__title--missing">
Post {{ post.external_post_id }} Post {{ post.external_post_id }}
</h3> </h3>
<p v-if="post.description_plain" class="fc-post-card__desc"> <p
{{ post.description_plain }} v-if="hasDescription" ref="descEl"
</p> class="fc-post-card__desc"
:class="{ 'fc-post-card__desc--clamped': !descExpanded }"
>{{ descText }}</p>
<p v-else class="fc-post-card__desc fc-post-card__desc--missing"> <p v-else class="fc-post-card__desc fc-post-card__desc--missing">
(no description) (no description)
</p> </p>
<div v-if="post.attachments?.length" class="fc-post-card__atts"> <!-- The ONLY in-place expansion: the post text, and only when it's
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon> actually truncated (server flag or a CSS-clamp overflow). -->
{{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }} <button
</div> v-if="canExpand" type="button" class="fc-post-card__more"
</div> @click="toggleDesc"
</div> >{{ descExpanded ? 'Show less' : 'Show more' }}</button>
<!-- Expanded body: title, full mosaic, full sanitized HTML description, <div v-if="attachments.length" class="fc-post-card__atts">
attachments. Lazy-loaded detail via getPostFull. -->
<div v-else class="fc-post-card__expanded">
<h2 v-if="plainTitle" class="fc-post-card__title-full">
{{ plainTitle }}
</h2>
<h2 v-else class="fc-post-card__title-full fc-post-card__title--missing">
Post {{ post.external_post_id }}
</h2>
<section v-if="images.length" class="fc-post-card__sec">
<PostImageGrid :thumbnails="images" />
<div v-if="!detailLoaded" class="fc-post-card__loading-hint">
Loading full image list
</div>
</section>
<section v-if="descriptionHtml" class="fc-post-card__sec">
<div class="fc-post-card__desc-full" v-html="descriptionHtml" />
</section>
<section v-else-if="detailLoaded" class="fc-post-card__sec">
<p class="fc-post-card__desc fc-post-card__desc--missing">(no description)</p>
</section>
<section v-if="attachments.length" class="fc-post-card__sec">
<h3 class="fc-post-card__h3">Attachments</h3>
<div class="fc-post-card__atts-full">
<a <a
v-for="att in attachments" :key="att.id" v-for="att in attachments" :key="att.id"
:href="att.download_url" download :href="att.download_url" download class="fc-post-card__att"
class="fc-post-card__att"
@click.stop
> >
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon> <v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
<span>{{ att.original_filename }}</span> <span>{{ att.original_filename }}</span>
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span> <span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
</a> </a>
</div> </div>
</section> </div>
</div> </div>
</v-card> </v-card>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { useModalStore } from '../../stores/modal.js'
import { usePostsStore } from '../../stores/posts.js' import { usePostsStore } from '../../stores/posts.js'
import { sanitizeHtml, toPlainText } from '../../utils/htmlSanitize.js' import { toPlainText } from '../../utils/htmlSanitize.js'
import PostEmptyThumbs from './PostEmptyThumbs.vue' import PostEmptyThumbs from './PostEmptyThumbs.vue'
import PostImageGrid from './PostImageGrid.vue'
const props = defineProps({ const props = defineProps({
post: { type: Object, required: true }, post: { type: Object, required: true },
}) })
const postsStore = usePostsStore() const postsStore = usePostsStore()
const modal = useModalStore()
// Per-card expand state. No global modal — each PostCard owns its own // Full detail (uncapped thumbnails + full description), fetched lazily — only
// view-mode and lazy-loaded detail. // when opening the modal for a post with >6 images, or expanding a
const expanded = ref(false) // server-truncated description.
const detail = ref(null) const detail = ref(null)
const detailLoaded = ref(false)
const detailError = ref(null)
// When expanded + detail loaded, prefer the uncapped detail thumbnails + const attachments = computed(() => props.post.attachments || [])
// full description. Falls back to feed shape if detail fetch is in flight const images = computed(() => props.post.thumbnails || [])
// or failed. const totalImages = computed(() => images.value.length + (props.post.thumbnails_more || 0))
const merged = computed(() => detail.value || props.post)
const images = computed(() => merged.value.thumbnails || [])
const attachments = computed(() => merged.value.attachments || [])
// Titles can arrive as stored HTML (e.g. "<strong>…</strong>"); render as
// plain text — the CSS makes the title bold.
const plainTitle = computed(() => toPlainText(props.post.post_title)) const plainTitle = computed(() => toPlainText(props.post.post_title))
// Compact-view hero+rail derived from the feed-shape (capped 6). const hero = computed(() => images.value[0])
const hero = computed(() => props.post.thumbnails?.[0]) const rail = computed(() => images.value.slice(1, 4))
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4)) const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
const moreCount = computed(() => { const moreCount = computed(() => {
const more = props.post.thumbnails_more || 0 const more = props.post.thumbnails_more || 0
const railLen = rail.value.length const extraShown = Math.max(0, images.value.length - visibleCount.value)
const extraShown = Math.max(0, (props.post.thumbnails?.length || 0) - 1 - railLen)
return more + extraShown return more + extraShown
}) })
@@ -180,49 +137,77 @@ const relativeDate = computed(() => {
return new Date(sortDateIso.value).toLocaleDateString() return new Date(sortDateIso.value).toLocaleDateString()
}) })
const descriptionHtml = computed(() => { // --- images → post-scoped modal ---------------------------------------
// Detail endpoint returns description_full as plain text (the service async function fullImageIds () {
// uses html_to_plain on the stored description). Render plain text in // Feed caps thumbnails at 6; load detail for the complete id list only when
// <p> wrappers; sanitize defensively in case the backend ever returns // there are more, so the modal can arrow through ALL of the post's images.
// raw HTML. if ((props.post.thumbnails_more || 0) === 0) {
const raw = merged.value.description_full || merged.value.description_plain return images.value.map((t) => t.image_id)
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('')
})
async function loadDetailIfNeeded () {
if (detailLoaded.value || detail.value) return
try {
detail.value = await postsStore.getPostFull(props.post.id)
detailLoaded.value = true
} catch (e) {
detailError.value = e.message
// Leave merged on feed-shape; the card still renders the truncated
// body so the operator isn't staring at a blank panel.
} }
if (!detail.value) {
try {
detail.value = await postsStore.getPostFull(props.post.id)
} catch { /* fall back to the capped feed list */ }
}
return (detail.value?.thumbnails || images.value).map((t) => t.image_id)
} }
function toggleExpanded () { async function openModal (imageId) {
expanded.value = !expanded.value modal.open(imageId, { postImageIds: await fullImageIds() })
if (expanded.value) loadDetailIfNeeded()
} }
function onCardClick (e) { async function openModalAtMore () {
// Inner interactive elements use @click.stop so they never reach here. const ids = await fullImageIds()
// Whole-card click expands a collapsed card; collapsing is chevron-only const first = ids[visibleCount.value] ?? ids[0]
// so a mosaic-image click on an expanded card can never accidentally if (first != null) modal.open(first, { postImageIds: ids })
// collapse the surrounding card. }
if (expanded.value) return
expanded.value = true // --- description "Show more" (text-only, in place, only when truncated) ----
loadDetailIfNeeded() const descExpanded = ref(false)
const cssOverflow = ref(false)
const descEl = ref(null)
const hasDescription = computed(() => !!props.post.description_plain)
const fullDescription = computed(() => detail.value?.description_full || null)
const descText = computed(() =>
descExpanded.value
? (fullDescription.value || props.post.description_plain)
: props.post.description_plain,
)
// Show the toggle iff the server truncated the text OR the clamp is cutting it.
const canExpand = computed(
() => props.post.description_truncated === true || cssOverflow.value,
)
function measureOverflow () {
const el = descEl.value
cssOverflow.value = !!el && el.scrollHeight > el.clientHeight + 1
}
let ro = null
onMounted(() => {
nextTick(measureOverflow)
// Re-measure when the card resizes (the container-query clamp differs by
// width). Guarded for happy-dom / older runtimes without ResizeObserver.
if (typeof ResizeObserver !== 'undefined' && descEl.value) {
ro = new ResizeObserver(() => { if (!descExpanded.value) measureOverflow() })
ro.observe(descEl.value)
}
})
onBeforeUnmount(() => { if (ro) { ro.disconnect(); ro = null } })
async function toggleDesc () {
if (!descExpanded.value) {
if (props.post.description_truncated && !fullDescription.value && !detail.value) {
try {
detail.value = await postsStore.getPostFull(props.post.id)
} catch { /* render the truncated text rather than nothing */ }
}
descExpanded.value = true
} else {
descExpanded.value = false
nextTick(measureOverflow)
}
} }
function formatBytes (n) { function formatBytes (n) {
@@ -239,20 +224,6 @@ function formatBytes (n) {
padding: 1rem; padding: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
container-type: inline-size; container-type: inline-size;
transition: border-color 0.15s ease;
}
.fc-post-card:not(.fc-post-card--expanded) {
cursor: pointer;
}
.fc-post-card:not(.fc-post-card--expanded):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--expanded {
border-color: rgb(var(--v-theme-accent) / 0.6);
} }
.fc-post-card__head { .fc-post-card__head {
@@ -273,7 +244,6 @@ function formatBytes (n) {
.fc-post-card__date, .fc-post-card__date,
.fc-post-card__meta { white-space: nowrap; } .fc-post-card__meta { white-space: nowrap; }
/* ---- COMPACT BODY ---- */
.fc-post-card__body { .fc-post-card__body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -288,6 +258,13 @@ function formatBytes (n) {
.fc-post-card__text { flex: 1 1 0; min-width: 0; } .fc-post-card__text { flex: 1 1 0; min-width: 0; }
} }
/* Image tiles are buttons (open the post-scoped modal) — reset button chrome. */
.fc-post-card__hero,
.fc-post-card__rail-cell,
.fc-post-card__rail-more {
display: block; padding: 0; border: 0; background: none;
cursor: pointer;
}
.fc-post-card__hero { .fc-post-card__hero {
width: 100%; width: 100%;
aspect-ratio: 16 / 10; aspect-ratio: 16 / 10;
@@ -297,10 +274,13 @@ function formatBytes (n) {
.fc-post-card__hero img { .fc-post-card__hero img {
width: 100%; height: 100%; width: 100%; height: 100%;
object-fit: cover; display: block; object-fit: cover; display: block;
transition: transform 0.2s ease, filter 0.2s ease;
} }
.fc-post-card__hero:hover img,
.fc-post-card__rail-cell:hover img { transform: scale(1.03); filter: brightness(1.08); }
.fc-post-card__rail { .fc-post-card__rail {
display: flex; gap: 6px; margin-top: 6px; display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap;
} }
.fc-post-card__rail-cell { .fc-post-card__rail-cell {
width: 80px; height: 80px; width: 80px; height: 80px;
@@ -318,6 +298,10 @@ function formatBytes (n) {
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
font-size: 0.85rem; font-size: 0.85rem;
} }
.fc-post-card__rail-more:hover {
border-color: rgb(var(--v-theme-accent));
color: rgb(var(--v-theme-accent));
}
.fc-post-card__title { .fc-post-card__title {
font-family: 'Fraunces', Georgia, serif; font-family: 'Fraunces', Georgia, serif;
@@ -346,67 +330,36 @@ function formatBytes (n) {
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.5; line-height: 1.5;
color: rgb(var(--v-theme-on-surface)); color: rgb(var(--v-theme-on-surface));
margin: 0 0 12px 0; margin: 0;
white-space: pre-wrap;
}
/* Clamp ONLY while collapsed; expanding drops the clamp to show it all. */
.fc-post-card__desc--clamped {
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
@container (min-width: 800px) {
.fc-post-card__desc--clamped { -webkit-line-clamp: 5; }
}
.fc-post-card__desc--missing { .fc-post-card__desc--missing {
font-style: italic; font-style: italic;
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
} }
@container (min-width: 800px) {
.fc-post-card__desc { -webkit-line-clamp: 5; } .fc-post-card__more {
margin-top: 6px;
padding: 0;
background: none; border: 0; cursor: pointer;
color: rgb(var(--v-theme-accent));
font-size: 0.85rem; font-weight: 600;
} }
.fc-post-card__more:hover { text-decoration: underline; }
.fc-post-card__atts { .fc-post-card__atts {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
/* ---- EXPANDED BODY ---- */
.fc-post-card__expanded {
display: flex;
flex-direction: column;
gap: 20px;
}
.fc-post-card__title-full {
font-family: 'Fraunces', Georgia, serif;
font-size: 22px;
font-weight: 700;
margin: 0;
color: rgb(var(--v-theme-on-surface));
}
@container (min-width: 800px) {
.fc-post-card__title-full { font-size: 26px; }
}
.fc-post-card__sec { margin: 0; }
.fc-post-card__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-card__loading-hint {
margin-top: 8px;
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__desc-full {
font-size: 0.95rem;
line-height: 1.55;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-card__desc-full :deep(p) { margin: 0 0 12px 0; }
.fc-post-card__desc-full :deep(a) { color: rgb(var(--v-theme-accent)); }
.fc-post-card__atts-full {
display: flex; flex-wrap: wrap; gap: 8px; display: flex; flex-wrap: wrap; gap: 8px;
margin-top: 12px;
} }
.fc-post-card__att { .fc-post-card__att {
display: inline-flex; display: inline-flex;
@@ -423,5 +376,6 @@ function formatBytes (n) {
color: rgb(var(--v-theme-accent)); color: rgb(var(--v-theme-accent));
border-color: rgb(var(--v-theme-accent)); border-color: rgb(var(--v-theme-accent));
} }
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); } .fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
</style> </style>
@@ -1,63 +0,0 @@
<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>
@@ -2,6 +2,42 @@
<v-card> <v-card>
<v-card-title>Import filters</v-card-title> <v-card-title>Import filters</v-card-title>
<v-card-text v-if="store.settings"> <v-card-text v-if="store.settings">
<!-- Near-duplicate dedup sensitivity, hoisted to the top: it's the
most-asked knob too loose and edits/variants of the same image
get dropped as duplicates on import. Slider with sane labelled
stops for the gist + a number field for precision; both bind the
same phash_threshold. -->
<div class="fc-phash">
<div class="fc-phash__title">Near-duplicate sensitivity</div>
<div class="fc-help mb-1">
How aggressively imports merge look-alike images (perceptual-hash
distance). <strong>Lower it if edits/variants of the same image are
being dropped as duplicates;</strong> raise it to collapse more
look-alikes. Applies to new imports.
</div>
<v-row align="center" no-gutters>
<v-col cols="12" sm="9">
<v-slider
v-model="local.phash_threshold"
:min="0" :max="16" :step="1"
:ticks="PHASH_TICKS" show-ticks="always" tick-size="4"
thumb-label color="accent" hide-details
class="fc-phash__slider"
@end="save"
/>
</v-col>
<v-col cols="8" sm="3" class="ps-sm-4 mt-2 mt-sm-0">
<v-text-field
v-model.number="local.phash_threshold"
label="Distance" type="number" min="0"
density="compact" hide-details @blur="save"
/>
</v-col>
</v-row>
</div>
<v-divider class="my-5" />
<v-row> <v-row>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-text-field <v-text-field
@@ -41,14 +77,6 @@
:disabled="!local.skip_single_color" @end="save" :disabled="!local.skip_single_color" @end="save"
/> />
</v-col> </v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model.number="local.phash_threshold"
label="Perceptual-hash threshold" type="number" min="0"
density="compact" hide-details @blur="save"
/>
<div class="fc-help">Higher = looser near-duplicate matching.</div>
</v-col>
</v-row> </v-row>
<v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable> <v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable>
@@ -66,6 +94,9 @@ import { reactive, watch } from 'vue'
import { useImportStore } from '../../stores/import.js' import { useImportStore } from '../../stores/import.js'
const store = useImportStore() const store = useImportStore()
// Labelled stops so the less-initiated get the gist without knowing what a
// Hamming distance is. 0 = byte-for-byte only; 10 = the shipped default.
const PHASH_TICKS = { 0: 'Exact', 4: 'Strict', 10: 'Default', 16: 'Loose' }
// Downloader + schedule-defaults fields moved to // Downloader + schedule-defaults fields moved to
// /subscriptions?tab=settings (operator decision 2026-05-27). This form // /subscriptions?tab=settings (operator decision 2026-05-27). This form
// now only owns image-import filters. // now only owns image-import filters.
@@ -89,4 +120,11 @@ async function save() {
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
margin-top: 2px; margin-top: 2px;
} }
.fc-phash__title {
font-size: 0.95rem;
font-weight: 600;
color: rgb(var(--v-theme-on-surface));
}
/* Headroom so the tick labels (Exact/Strict/Default/Loose) aren't clipped. */
.fc-phash__slider { margin-bottom: 18px; }
</style> </style>
+3 -3
View File
@@ -18,9 +18,9 @@ export const useModalStore = defineStore('modal', () => {
const inflight = useInflightToken() const inflight = useInflightToken()
// Post-scoped cycle. When set, prev/next cycles within this array // Post-scoped cycle. When set, prev/next cycles within this array
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When // (used by PostCard image clicks — the modal is scoped to that post's
// null, prev/next falls back to current.value.neighbors (the // images). When null, prev/next falls back to current.value.neighbors
// gallery-store-driven /api/gallery/image/<id> neighbors). // (the gallery-store-driven /api/gallery/image/<id> neighbors).
const postImageIds = ref(null) const postImageIds = ref(null)
const postImageIndex = ref(0) const postImageIndex = ref(0)
+6 -5
View File
@@ -44,14 +44,15 @@
</v-window-item> </v-window-item>
<v-window-item value="import"> <v-window-item value="import">
<!-- Order: trigger → recent tasks → filters. Tasks sit directly <!-- Order: filters → trigger → recent tasks. Filters hoisted above the
below the trigger so operator sees hit/miss feedback without trigger (operator-flagged 2026-06-04); the task list stays
scrolling past the filter card (operator-flagged 2026-05-25). --> directly below the trigger so hit/miss feedback is adjacent to the
button that produced it (operator-flagged 2026-05-25). -->
<ImportFiltersForm />
<v-divider class="my-6" />
<ImportTriggerPanel /> <ImportTriggerPanel />
<v-divider class="my-6" /> <v-divider class="my-6" />
<ImportTaskList /> <ImportTaskList />
<v-divider class="my-6" />
<ImportFiltersForm />
</v-window-item> </v-window-item>
<v-window-item value="cleanup"> <v-window-item value="cleanup">
+48 -13
View File
@@ -1,26 +1,61 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { describe, it, expect } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import PostCard from '../../src/components/posts/PostCard.vue' import PostCard from '../../src/components/posts/PostCard.vue'
import { useModalStore } from '../../src/stores/modal.js'
import { freshPinia, mountComponent } from '../support/mountComponent.js' import { freshPinia, mountComponent } from '../support/mountComponent.js'
const now = new Date().toISOString()
const BASE = {
id: 1, external_post_id: 'P1',
post_title: '<strong>Hello World</strong>',
post_url: 'https://x/1', post_date: now, downloaded_at: now,
artist: { id: 1, name: 'Sabu', slug: 'sabu' },
source: { id: 1, platform: 'subscribestar' },
thumbnails: [], thumbnails_more: 0, attachments: [],
}
describe('PostCard', () => { describe('PostCard', () => {
it('renders the HTML-stripped title and the artist', () => { it('renders the HTML-stripped title and the artist', () => {
const pinia = freshPinia() const post = { ...BASE, description_plain: 'a description' }
const now = new Date().toISOString() const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
const post = {
id: 1, external_post_id: 'P1',
post_title: '<strong>Hello World</strong>',
post_url: 'https://x/1', post_date: now, downloaded_at: now,
description_plain: 'a description',
artist: { id: 1, name: 'Sabu', slug: 'sabu' },
source: { id: 1, platform: 'subscribestar' },
thumbnails: [], thumbnails_more: 0, attachments: [],
}
const w = mountComponent(PostCard, { props: { post }, pinia })
const t = w.text() const t = w.text()
expect(t).toContain('Hello World') // plain title expect(t).toContain('Hello World') // plain title
expect(t).not.toContain('<strong>') // tags stripped expect(t).not.toContain('<strong>') // tags stripped
expect(t).toContain('Sabu') // artist (RouterLink slot) expect(t).toContain('Sabu') // artist (RouterLink slot)
}) })
it('shows a "Show more" toggle only when the description is truncated', () => {
const truncated = mountComponent(PostCard, {
props: { post: { ...BASE, description_plain: 'lots of text…', description_truncated: true } },
pinia: freshPinia(),
})
expect(truncated.text()).toContain('Show more')
const full = mountComponent(PostCard, {
props: { post: { ...BASE, description_plain: 'short', description_truncated: false } },
pinia: freshPinia(),
})
expect(full.text()).not.toContain('Show more')
})
it('clicking an image opens the post-scoped modal (never expands the card)', async () => {
const pinia = freshPinia()
const modal = useModalStore()
const openSpy = vi.spyOn(modal, 'open').mockResolvedValue()
const post = {
...BASE,
description_plain: 'd',
thumbnails: [
{ image_id: 10, thumbnail_url: '/a' },
{ image_id: 11, thumbnail_url: '/b' },
],
thumbnails_more: 0,
}
const w = mountComponent(PostCard, { props: { post }, pinia })
await w.find('.fc-post-card__hero').trigger('click')
await flushPromises()
expect(openSpy).toHaveBeenCalledWith(10, { postImageIds: [10, 11] })
})
}) })