feat: the unified card shows referenced images, their text, and an undo (4402)
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 18s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m22s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m38s
CI and images / smoke-web (push) Successful in 59s
CI and images / promote (push) Skipped

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-24 11:25:52 -04:00
co-authored by Claude Opus 5.5
parent 30a263a47a
commit 7aa074d1a9
5 changed files with 328 additions and 9 deletions
+146 -6
View File
@@ -30,6 +30,12 @@
<span v-if="totalImages" class="fc-post-card__meta">
· {{ totalImages }} image{{ totalImages === 1 ? '' : 's' }}
</span>
<!-- #4402. Referenced images are counted apart from the post's own: a
Patreon card must not read as though the creator posted Discord's
files on Patreon. -->
<span v-if="refImages.length" class="fc-post-card__meta fc-post-card__ref-meta">
· +{{ refImages.length }} from Discord
</span>
<!-- Only on a grouping that has actually grown. An ordinary post can
never show this, and a grouping that has not grown says nothing —
an "updated" label that is always present teaches you to ignore it. -->
@@ -66,9 +72,16 @@
<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)"
:class="{ 'fc-post-card__rail-cell--ref': t.role }"
:title="t.role ? refTitle(t) : undefined"
:aria-label="t.role ? `Open ${refTitle(t)}` : 'Open image'"
@click="openModal(t.image_id)"
>
<img :src="t.thumbnail_url" alt="thumbnail" loading="lazy" />
<v-icon
v-if="t.role" icon="mdi-link-variant" size="x-small"
class="fc-post-card__ref-badge"
/>
</button>
<button
v-if="moreCount > 0" type="button"
@@ -138,9 +151,40 @@
query params" — so this works from Latest or Browse alike without
the card reaching for useRoute(), which it has no other reason to
know about. -->
<div v-if="associations.length" class="fc-post-card__assoc">
<!-- #4402: the unified card. A teaser is a pointer, so the card shows
what it points at — the drop's images join the rail above and
its text sits here — by REFERENCE: the Discord posts keep their
own place in the feed, and nothing here moves them. A link FC made
by itself says so and offers the undo the operator asked for. -->
<div v-if="unified" class="fc-post-card__unified">
<div
v-for="l in unified.links" :key="l.association_id"
class="fc-post-card__unified-head"
>
<v-icon icon="mdi-link-variant" size="x-small" />
<RouterLink
v-for="a in associations" :key="a.id"
:to="{ query: { post_id: l.post_id } }"
class="fc-post-card__assoc-link"
>{{ linkLabel(l) }}</RouterLink>
<button
type="button" class="fc-post-card__undo"
:disabled="unlinking === l.association_id"
@click="undoLink(l)"
>{{ l.linked_by === 'fc' ? 'Undo' : 'Unlink' }}</button>
</div>
<div
v-for="t in unified.texts" :key="t.post_id"
class="fc-post-card__unified-text"
>
<div class="fc-post-card__unified-meta">
Discord · {{ shortDate(t.date) }}{{ t.role === 'variant' ? ' · variant' : '' }}
</div>
<p class="fc-post-card__unified-body">{{ t.text }}</p>
</div>
</div>
<div v-if="plainLinks.length" class="fc-post-card__assoc">
<RouterLink
v-for="a in plainLinks" :key="a.id"
:to="{ query: { post_id: a.post_id } }"
class="fc-post-card__assoc-link"
>
@@ -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;
@@ -61,6 +61,47 @@
</v-col>
</v-row>
<!-- #4402. What a link DOES once made: the teaser's card shows the drop
and the piece's other variants by reference. Nothing is moved — these
two windows only decide what the card shows and which duplicate the
feed leaves out. -->
<div class="text-body-2 mt-4 mb-1"><strong>On the teaser's card</strong></div>
<div class="text-caption fc-muted mb-2">
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.
</div>
<v-row>
<v-col cols="12" sm="6">
<SettingNumberField
v-model="foldHours" label="Hide the drop's own card within (hours)"
:min="0" :step="1"
density="comfortable" max-width="none"
:disabled="!enabled" @change="store.setFoldHours(Number(foldHours))"
/>
<div class="text-caption fc-muted mt-1">
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.
</div>
</v-col>
<v-col cols="12" sm="6">
<SettingNumberField
v-model="familyDays" label="Reach back for variants (days)"
:min="0" :step="1"
density="comfortable" max-width="none"
:disabled="!enabled" @change="store.setFamilyDays(Number(familyDays))"
/>
<div class="text-caption fc-muted mt-1">
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.
</div>
</v-col>
</v-row>
<div class="d-flex align-center mt-4 mb-2" style="gap:12px">
<strong class="text-body-2">
{{ 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
+18 -2
View File
@@ -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
}
})
+30 -1
View File
@@ -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,
}
})
+89
View File
@@ -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')
})
})