@@ -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')
+ })
+})