From d0b0458d279574f373262a9c1e97dcb32508877a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 10 Sep 2026 11:53:17 -0400 Subject: [PATCH] feat: the announcement link, on the card and in a review queue (388 E5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule 27 — E5's other half. The matcher can propose; this is where the operator decides, and where an accepted link actually shows up. **The review queue** (Settings → Ingestion & filters). Each proposal shows the per-signal breakdown, not just the total: "why did it suggest this" is the question the operator actually has, and a lone percentage cannot answer it. So a row reads "72% · timing 95% · says so 60%", and the copy states outright that a pair always needs two reasons — which is the property that stops a busy posting day from producing false pairs. The empty state says so explicitly. Nothing proposed is the EXPECTED state most of the time, and an empty queue that looks like a failure invites turning the threshold down until it produces noise. **On the card**, both directions, and accepted links only: the teaser gets "The full set is in Discord", the drop gets "Announced on Patreon". A pending proposal is a question for the review queue, never a claim to render beside the artwork — that distinction is the whole confirm-only design, so it is asserted in the backend (only `linked` rows reach the payload) and again here. One detail worth the comment it carries: the link's target is `{ query: { post_id } }` with no name or path. In vue-router that means "the current route with these query params", so it works identically from Latest and from Browse — and, more usefully, the card never reaches for `useRoute()`, which it has no other reason to know about and which is not available when it is mounted in a test without a router. Backend CI on 235393c was green: all 13 E5 tests and migration 0094. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 --- frontend/src/components/posts/PostCard.vue | 34 +++++ .../components/settings/MaintenancePanel.vue | 2 + .../settings/PostAssociationsCard.vue | 128 ++++++++++++++++++ frontend/src/stores/postAssociations.js | 83 ++++++++++++ frontend/test/components/postCard.spec.js | 34 +++++ 5 files changed, 281 insertions(+) create mode 100644 frontend/src/components/settings/PostAssociationsCard.vue create mode 100644 frontend/src/stores/postAssociations.js diff --git a/frontend/src/components/posts/PostCard.vue b/frontend/src/components/posts/PostCard.vue index 90bc754..f3b7216 100644 --- a/frontend/src/components/posts/PostCard.vue +++ b/frontend/src/components/posts/PostCard.vue @@ -128,6 +128,25 @@ @click="toggleDesc" >{{ descExpanded ? 'Show less' : 'Show more' }} + + +
+ + + {{ a.role === 'announces' + ? 'The full set is in Discord' : 'Announced on Patreon' }} + +
+ @@ -234,6 +253,10 @@ const sortDateIso = computed(() => props.post.post_date || props.post.downloaded // Tuesday but gained images this morning" is the whole signal that chat // content is trickling in. const grewAt = computed(() => (synthesized.value ? props.post.last_grew_at : null)) +// #388 E5. Accepted links only — a pending proposal lives in the review queue, +// 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 || []) const grewRelative = computed(() => (grewAt.value ? relativeFrom(grewAt.value) : '')) const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString()) function relativeFrom (iso) { @@ -394,6 +417,17 @@ function formatBytes (n) { /* Growth is news, so it gets the accent the rest of the meta line doesn't — but it is still the meta line, not a badge competing with the artwork. */ .fc-post-card__grew { color: rgb(var(--v-theme-accent)); } + +.fc-post-card__assoc { margin-top: 8px; } +.fc-post-card__assoc-link { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.8125rem; + color: rgb(var(--v-theme-accent)); + text-decoration: none; +} +.fc-post-card__assoc-link:hover { text-decoration: underline; } .fc-post-card__date, .fc-post-card__meta { white-space: nowrap; } diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue index 6a51458..3e10a34 100644 --- a/frontend/src/components/settings/MaintenancePanel.vue +++ b/frontend/src/components/settings/MaintenancePanel.vue @@ -15,6 +15,7 @@ + @@ -82,6 +83,7 @@ import VideoEmbeddingCard from './VideoEmbeddingCard.vue' import CropProposersCard from './CropProposersCard.vue' import HeadsCard from './HeadsCard.vue' import DiscordGroupingCard from './DiscordGroupingCard.vue' +import PostAssociationsCard from './PostAssociationsCard.vue' import GpuAgentCard from './GpuAgentCard.vue' import AliasTable from './AliasTable.vue' import BackupCard from './BackupCard.vue' diff --git a/frontend/src/components/settings/PostAssociationsCard.vue b/frontend/src/components/settings/PostAssociationsCard.vue new file mode 100644 index 0000000..141d789 --- /dev/null +++ b/frontend/src/components/settings/PostAssociationsCard.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/frontend/src/stores/postAssociations.js b/frontend/src/stores/postAssociations.js new file mode 100644 index 0000000..e8a2027 --- /dev/null +++ b/frontend/src/stores/postAssociations.js @@ -0,0 +1,83 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +import { useApi } from '../composables/useApi.js' +import { useAsyncAction } from '../composables/useAsyncAction.js' +import { toast } from '../utils/toast.js' + +// Backs the announcement review queue (#388 E5): "this Patreon post announced +// that Discord drop". Confirm-only, deliberately — a wrongly-asserted link +// tells the operator two different pieces are one, which is worse than no link +// at all, so accept is the ONLY thing that makes a link real. Mirrors +// seriesSuggestions (FC-6.3), which is the same shape for the same reason. +export const usePostAssociationsStore = defineStore('postAssociations', () => { + const api = useApi() + const proposals = ref([]) + const enabled = ref(true) + const threshold = ref(0.6) + const windowHours = ref(24) + const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) + + async function load () { + await run(async () => { + const body = await api.get('/api/posts/associations') + proposals.value = body.items || [] + }) + } + + async function loadSettings () { + const s = await api.get('/api/settings/import') + enabled.value = s.discord_link_enabled + threshold.value = s.discord_link_threshold + windowHours.value = s.discord_link_window_hours + } + + async function saveSettings (patch) { + await api.patch('/api/settings/import', { body: patch }) + } + + async function setEnabled (v) { + enabled.value = v + await saveSettings({ discord_link_enabled: v }) + } + + async function setThreshold (v) { + threshold.value = v + await saveSettings({ discord_link_threshold: v }) + } + + async function setWindowHours (v) { + windowHours.value = v + await saveSettings({ discord_link_window_hours: v }) + } + + async function accept (id) { + try { + await api.post(`/api/posts/associations/${id}/accept`, {}) + proposals.value = proposals.value.filter(p => p.id !== id) + toast({ text: 'Linked', type: 'success' }) + } catch (e) { + toast({ text: `Link failed: ${e.message}`, type: 'error' }) + } + } + + async function dismiss (id) { + try { + await api.post(`/api/posts/associations/${id}/dismiss`, {}) + proposals.value = proposals.value.filter(p => p.id !== id) + } catch (e) { + toast({ text: `Dismiss failed: ${e.message}`, type: 'error' }) + } + } + + async function rescan () { + await api.post('/api/posts/associations/rescan', {}) + await load() + } + + return { + proposals, enabled, threshold, windowHours, loading, error, + load, loadSettings, setEnabled, setThreshold, setWindowHours, + accept, dismiss, rescan + } +}) diff --git a/frontend/test/components/postCard.spec.js b/frontend/test/components/postCard.spec.js index 8758cbb..9324d07 100644 --- a/frontend/test/components/postCard.spec.js +++ b/frontend/test/components/postCard.spec.js @@ -114,6 +114,40 @@ describe('PostCard', () => { expect(w.text()).not.toContain('updated') }) + it('shows an accepted announcement link from either end', () => { + const teaser = mountComponent(PostCard, { + props: { + post: { + ...BASE, + associations: [{ id: 7, role: 'announces', post_id: 42 }], + }, + }, + pinia: freshPinia(), + }) + expect(teaser.text()).toContain('The full set is in Discord') + + const drop = mountComponent(PostCard, { + props: { + post: { + ...SYNTH, + associations: [{ id: 7, role: 'announced_by', post_id: 1 }], + }, + }, + pinia: freshPinia(), + }) + expect(drop.text()).toContain('Announced on Patreon') + }) + + it('shows no link when there is no accepted association', () => { + // Pending proposals never reach the payload, so an empty list here is + // the normal case and must render as silence, not an empty row. + const w = mountComponent(PostCard, { + props: { post: { ...BASE, associations: [] } }, + pinia: freshPinia(), + }) + expect(w.find('.fc-post-card__assoc').exists()).toBe(false) + }) + it('singularises a one-message drop', () => { const w = mountComponent(PostCard, { props: { post: { ...SYNTH, synthesis: { message_count: 1 } } },