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