From 7aa074d1a93ac961224c2700cc4bc17eeccf732c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 24 Sep 2026 11:25:52 -0400 Subject: [PATCH] feat: the unified card shows referenced images, their text, and an undo (4402) The linked drop's images and the piece's variants join the teaser's rail, after its own images, each with a corner badge. The meta line counts them apart ("+N from Discord"), so a Patreon card never reads as though the creator posted Discord's files there. The text of each referenced post sits under the teaser's body. If FC made the link, the card says so, names the matching working name and offers Undo. If a person accepted it, it offers Unlink and makes no claim about FC. Both dismiss the pair. The kept row then stops the next sweep from linking it straight back. The announcement settings card gets the two windows: how close a drop must be before its own card is left out of the feed, and how far to reach for variants. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR --- frontend/src/components/posts/PostCard.vue | 152 +++++++++++++++++- .../settings/PostAssociationsCard.vue | 45 ++++++ frontend/src/stores/postAssociations.js | 20 ++- frontend/src/stores/posts.js | 31 +++- frontend/test/components/postCard.spec.js | 89 ++++++++++ 5 files changed, 328 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/posts/PostCard.vue b/frontend/src/components/posts/PostCard.vue index 8a8cfdc..2d7be9f 100644 --- a/frontend/src/components/posts/PostCard.vue +++ b/frontend/src/components/posts/PostCard.vue @@ -30,6 +30,12 @@ · {{ totalImages }} image{{ totalImages === 1 ? '' : 's' }} + + + · +{{ refImages.length }} from Discord + @@ -66,9 +72,16 @@ + +
+
+ Discord · {{ shortDate(t.date) }}{{ t.role === 'variant' ? ' · variant' : '' }} +
+

{{ t.text }}

+
+ +
@@ -191,6 +235,7 @@ import { RouterLink } from 'vue-router' import { useModalStore } from '../../stores/modal.js' import { usePostsStore } from '../../stores/posts.js' import { toPlainText } from '../../utils/htmlSanitize.js' +import { toast } from '../../utils/toast.js' import PostSeriesMenu from './PostSeriesMenu.vue' import PostTranslationControl from './PostTranslationControl.vue' @@ -207,8 +252,16 @@ const modal = useModalStore() const detail = ref(null) const attachments = computed(() => props.post.attachments || []) -const images = computed(() => props.post.thumbnails || []) -const totalImages = computed(() => images.value.length + (props.post.thumbnails_more || 0)) +// #4402. A teaser's card also shows what it points at: the linked drop's +// images and the piece's other variants, each tagged with the post it really +// belongs to. They follow the post's own images so the teaser keeps its hero, +// and the post's own capped list stays a PREFIX of everything shown — which is +// what lets the "+N" tile and the modal playlist keep indexing correctly. +const unified = computed(() => props.post.unified || null) +const ownImages = computed(() => props.post.thumbnails || []) +const refImages = computed(() => unified.value?.thumbnails || []) +const images = computed(() => [...ownImages.value, ...refImages.value]) +const totalImages = computed(() => ownImages.value.length + (props.post.thumbnails_more || 0)) const plainTitle = computed(() => toPlainText(props.post.post_title)) // #388 E2. Non-null `synthesized_by` means FC authored this row by grouping a @@ -273,6 +326,44 @@ const grewAt = computed(() => (synthesized.value ? props.post.last_grew_at : nul // never beside the artwork. Defaults to [] so a post dict from before the // feature (or composed by hand) renders without a link rather than throwing. const associations = computed(() => props.post.associations || []) +// The teaser side of a link is drawn by the unified block when there is one; +// only a link it does not cover (the drop's "announced by", or a feed payload +// from before #4402) keeps the plain text link. +const plainLinks = computed(() => + unified.value + ? associations.value.filter((a) => a.role !== 'announces') + : associations.value, +) + +function linkLabel (l) { + if (l.linked_by === 'fc') { + return l.token + ? `Linked by FabledCurator — matched on “${l.token}”` + : 'Linked by FabledCurator' + } + return 'Linked to its Discord drop' +} + +function refTitle (t) { + return t.role === 'variant' ? 'a variant from Discord' : 'from the Discord drop' +} + +function shortDate (iso) { + return new Date(iso).toLocaleDateString() +} + +const unlinking = ref(null) +async function undoLink (l) { + unlinking.value = l.association_id + try { + await postsStore.unlink(props.post.id, l.association_id) + toast({ text: 'Unlinked — the Discord drop returns to the feed on the next load', type: 'success' }) + } catch (e) { + toast({ text: `Unlink failed: ${e.message}`, type: 'error' }) + } finally { + unlinking.value = null + } +} const grewRelative = computed(() => (grewAt.value ? relativeFrom(grewAt.value) : '')) const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString()) function relativeFrom (iso) { @@ -298,7 +389,8 @@ async function fullImageIds () { 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) + const own = detail.value?.thumbnails || ownImages.value + return [...own, ...refImages.value].map((t) => t.image_id) } async function openModal (imageId) { @@ -447,6 +539,54 @@ function formatBytes (n) { .fc-post-card__grew { color: rgb(var(--v-theme-accent)); } .fc-post-card__assoc { margin-top: 8px; } + +/* #4402 — the unified block. Quiet by design: it explains the references in + the rail, it does not compete with them. */ +.fc-post-card__unified { + margin-top: 10px; + padding-left: 10px; + border-left: 2px solid rgba(var(--v-theme-accent), 0.5); +} +.fc-post-card__unified-head { + display: flex; align-items: center; flex-wrap: wrap; gap: 6px; + font-size: 0.8125rem; + color: rgb(var(--v-theme-accent)); +} +.fc-post-card__undo { + padding: 0; border: 0; background: none; cursor: pointer; + font-size: 0.75rem; font-weight: 600; + color: rgb(var(--v-theme-on-surface-variant)); +} +.fc-post-card__undo:hover { color: rgb(var(--v-theme-accent)); text-decoration: underline; } +.fc-post-card__undo:disabled { cursor: default; text-decoration: none; } +.fc-post-card__unified-text { margin-top: 6px; } +.fc-post-card__unified-meta { + font-size: 0.7rem; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.04em; + color: rgb(var(--v-theme-on-surface-variant)); +} +.fc-post-card__unified-body { + margin: 2px 0 0; + font-size: 0.85rem; line-height: 1.45; + white-space: pre-wrap; + color: rgb(var(--v-theme-on-surface)); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.fc-post-card__ref-meta { color: rgb(var(--v-theme-accent)); } + +/* A referenced tile carries a corner badge: the images are Discord's, shown + here, and the card must not pass them off as the teaser's own. */ +.fc-post-card__rail-cell { position: relative; } +.fc-post-card__rail-cell--ref { outline: 1px solid rgba(var(--v-theme-accent), 0.55); outline-offset: -1px; } +.fc-post-card__ref-badge { + position: absolute; top: 4px; right: 4px; + padding: 2px; border-radius: 4px; + background: rgba(var(--v-theme-surface), 0.85); + color: rgb(var(--v-theme-accent)); +} .fc-post-card__assoc-link { display: inline-flex; align-items: center; diff --git a/frontend/src/components/settings/PostAssociationsCard.vue b/frontend/src/components/settings/PostAssociationsCard.vue index c267fde..0e9f996 100644 --- a/frontend/src/components/settings/PostAssociationsCard.vue +++ b/frontend/src/components/settings/PostAssociationsCard.vue @@ -61,6 +61,47 @@ + +
On the teaser's card
+
+ A linked teaser shows the Discord drop it announced — its images and its + message — and the other versions of the same piece: the wips, alts and + censor passes posted under the same working name. They stay where they + landed in the feed; the card only shows them together. +
+ + + +
+ A drop this close to its teaser is the same release shown twice, so + only the teaser's card stays in the feed. A drop further away keeps + its own card. 0 hides nothing. +
+
+ + +
+ How far either side of the teaser to look for the rest of the piece. + Measured on real drops: a piece's versions span up to about six + weeks, while unrelated pieces that happen to share a name are years + apart. 0 shows the drop alone. +
+
+
+
{{ store.proposals.length }} waiting for review @@ -130,11 +171,15 @@ const enabled = ref(true) const auto = ref(true) const threshold = ref(0.6) const windowHours = ref(24) +const foldHours = ref(24) +const familyDays = ref(60) watch(() => store.enabled, (v) => { enabled.value = v }, { immediate: true }) watch(() => store.auto, (v) => { auto.value = v }, { immediate: true }) watch(() => store.threshold, (v) => { threshold.value = v }, { immediate: true }) watch(() => store.windowHours, (v) => { windowHours.value = v }, { immediate: true }) +watch(() => store.foldHours, (v) => { foldHours.value = v }, { immediate: true }) +watch(() => store.familyDays, (v) => { familyDays.value = v }, { immediate: true }) onMounted(async () => { // Both swallow their own failures: a settings read that fails should not diff --git a/frontend/src/stores/postAssociations.js b/frontend/src/stores/postAssociations.js index b98cc55..71cb451 100644 --- a/frontend/src/stores/postAssociations.js +++ b/frontend/src/stores/postAssociations.js @@ -20,6 +20,9 @@ export const usePostAssociationsStore = defineStore('postAssociations', () => { const auto = ref(true) const threshold = ref(0.6) const windowHours = ref(24) + // #4402 — what a link shows once made. See PostAssociationsCard. + const foldHours = ref(24) + const familyDays = ref(60) const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) async function load () { @@ -35,6 +38,8 @@ export const usePostAssociationsStore = defineStore('postAssociations', () => { auto.value = s.discord_link_auto threshold.value = s.discord_link_threshold windowHours.value = s.discord_link_window_hours + foldHours.value = s.discord_link_fold_hours + familyDays.value = s.discord_family_window_days } async function saveSettings (patch) { @@ -61,6 +66,16 @@ export const usePostAssociationsStore = defineStore('postAssociations', () => { await saveSettings({ discord_link_window_hours: v }) } + async function setFoldHours (v) { + foldHours.value = v + await saveSettings({ discord_link_fold_hours: v }) + } + + async function setFamilyDays (v) { + familyDays.value = v + await saveSettings({ discord_family_window_days: v }) + } + async function accept (id) { try { await api.post(`/api/posts/associations/${id}/accept`, {}) @@ -86,8 +101,9 @@ export const usePostAssociationsStore = defineStore('postAssociations', () => { } return { - proposals, enabled, auto, threshold, windowHours, loading, error, + proposals, enabled, auto, threshold, windowHours, foldHours, familyDays, + loading, error, load, loadSettings, setEnabled, setAuto, setThreshold, setWindowHours, - accept, dismiss, rescan + setFoldHours, setFamilyDays, accept, dismiss, rescan } }) diff --git a/frontend/src/stores/posts.js b/frontend/src/stores/posts.js index 5c7ff2f..4fd3d90 100644 --- a/frontend/src/stores/posts.js +++ b/frontend/src/stores/posts.js @@ -89,6 +89,35 @@ export const usePostsStore = defineStore('posts', () => { return res } + // Undo a link on a unified card (#4402). The operator chose "nest + // automatically, with visible undo" — so the undo is the review queue's own + // dismiss, whose kept row is what stops the next sweep linking the pair + // straight back. Patches the loaded teaser in place so the card drops its + // references at once; the drop's own card is not in the loaded page (it was + // folded out), so it returns on the next load rather than being guessed into + // position here. + async function unlink(postId, associationId) { + await api.post(`/api/posts/associations/${associationId}/dismiss`, {}) + const item = items.value.find((p) => p.id === postId) + if (!item) return + item.associations = (item.associations || []).filter((a) => a.id !== associationId) + const unified = item.unified + if (!unified) return + const gone = unified.links.find((l) => l.association_id === associationId) + const links = unified.links.filter((l) => l.association_id !== associationId) + // Variants are keyed on the whole seed, so with one link left they may no + // longer all belong; with none left there is nothing to show at all. Drop + // what is certainly gone and let the next load recompute the rest. + item.unified = links.length + ? { + ...unified, + links, + thumbnails: unified.thumbnails.filter((t) => t.post_id !== gone?.post_id), + texts: unified.texts.filter((t) => t.post_id !== gone?.post_id), + } + : null + } + // Filter overlay for the around/older/newer (in-context anchored) // path. Keep this distinct from `filters.value` (the down-only feed) // so a normal-feed filter change doesn't leak into an active anchored @@ -186,7 +215,7 @@ export const usePostsStore = defineStore('posts', () => { return { items, cursor, loading, done, error, filters, cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId, - loadInitial, loadMore, getPostFull, applyTranslationOverride, + loadInitial, loadMore, getPostFull, applyTranslationOverride, unlink, loadAround, loadOlder, loadNewer, } }) diff --git a/frontend/test/components/postCard.spec.js b/frontend/test/components/postCard.spec.js index 9324d07..b96f779 100644 --- a/frontend/test/components/postCard.spec.js +++ b/frontend/test/components/postCard.spec.js @@ -4,6 +4,7 @@ import { flushPromises } from '@vue/test-utils' import PostCard from '../../src/components/posts/PostCard.vue' import { useModalStore } from '../../src/stores/modal.js' +import { usePostsStore } from '../../src/stores/posts.js' import { freshPinia, mountComponent } from '../support/mountComponent.js' const now = new Date().toISOString() @@ -201,3 +202,91 @@ describe('PostCard', () => { expect(openSpy).toHaveBeenCalledWith(10, { playlistIds: [10, 11] }) }) }) + +// #4402. A teaser is a pointer, so its card shows what it points at — by +// REFERENCE. These pin the honesty half as hard as the feature half: the +// referenced images are marked as Discord's, and a link FC made by itself says +// so and offers the undo the operator chose over a silent merge. +describe('the unified card', () => { + const UNIFIED = { + links: [{ association_id: 7, post_id: 42, linked_by: 'fc', token: '0-k' }], + thumbnails: [ + { image_id: 200, thumbnail_url: '/d0', post_id: 42, role: 'drop' }, + { image_id: 201, thumbnail_url: '/v0', post_id: 40, role: 'variant' }, + ], + variant_count: 1, + texts: [ + { post_id: 42, role: 'drop', date: now, text: '@everyone the full set' }, + { post_id: 40, role: 'variant', date: now, text: 'wip, feedback welcome' }, + ], + } + const TEASER = { + ...BASE, + description_plain: 'Full set in the server', + thumbnails: [{ image_id: 10, thumbnail_url: '/a' }], + associations: [{ id: 7, role: 'announces', post_id: 42 }], + unified: UNIFIED, + } + + it('shows the drop and its variants beside the teaser, marked as references', () => { + const w = mountComponent(PostCard, { props: { post: TEASER }, pinia: freshPinia() }) + const refs = w.findAll('.fc-post-card__rail-cell--ref') + expect(refs).toHaveLength(2) + // Counted apart from the teaser's own, so the card never reads as though + // the creator put Discord's files on Patreon. + expect(w.text()).toContain('1 image') + expect(w.text()).toContain('+2 from Discord') + }) + + it('carries the text of everything it unifies', () => { + const w = mountComponent(PostCard, { props: { post: TEASER }, pinia: freshPinia() }) + expect(w.text()).toContain('@everyone the full set') + expect(w.text()).toContain('wip, feedback welcome') + }) + + it('says FC made the link, on what, and offers an undo', () => { + const w = mountComponent(PostCard, { props: { post: TEASER }, pinia: freshPinia() }) + expect(w.text()).toContain('Linked by FabledCurator') + expect(w.text()).toContain('0-k') + expect(w.find('.fc-post-card__undo').text()).toBe('Undo') + // The old text link would say the same thing twice. + expect(w.text()).not.toContain('The full set is in Discord') + }) + + it('never claims FC made a link a person accepted', () => { + const post = { + ...TEASER, + unified: { ...UNIFIED, links: [{ ...UNIFIED.links[0], linked_by: 'operator' }] }, + } + const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() }) + expect(w.text()).not.toContain('Linked by FabledCurator') + expect(w.find('.fc-post-card__undo').text()).toBe('Unlink') + }) + + it('undo dismisses that one link', async () => { + const pinia = freshPinia() + const store = usePostsStore() + const spy = vi.spyOn(store, 'unlink').mockResolvedValue() + const w = mountComponent(PostCard, { props: { post: TEASER }, pinia }) + await w.find('.fc-post-card__undo').trigger('click') + await flushPromises() + expect(spy).toHaveBeenCalledWith(1, 7) + }) + + it('arrows through the teaser first, then what it references', async () => { + const pinia = freshPinia() + const openSpy = vi.spyOn(useModalStore(), 'open').mockResolvedValue() + const w = mountComponent(PostCard, { props: { post: TEASER }, pinia }) + await w.find('.fc-post-card__hero').trigger('click') + await flushPromises() + expect(openSpy).toHaveBeenCalledWith(10, { playlistIds: [10, 200, 201] }) + }) + + it('an ordinary post is untouched', () => { + const w = mountComponent(PostCard, { + props: { post: { ...BASE, unified: null } }, pinia: freshPinia(), + }) + expect(w.find('.fc-post-card__unified').exists()).toBe(false) + expect(w.text()).not.toContain('from Discord') + }) +})