feat: the announcement link, on the card and in a review queue (388 E5)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m1s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m11s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m1s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m11s
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
@@ -128,6 +128,25 @@
|
||||
@click="toggleDesc"
|
||||
>{{ descExpanded ? 'Show less' : 'Show more' }}</button>
|
||||
|
||||
<!-- #388 E5: the accepted announcement link, both directions. Only
|
||||
ACCEPTED ones reach the payload, so anything rendered here is
|
||||
something the operator agreed to — never a proposal. -->
|
||||
<!-- A location with no name/path is "the current route, with these
|
||||
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">
|
||||
<RouterLink
|
||||
v-for="a in associations" :key="a.id"
|
||||
:to="{ query: { post_id: a.post_id } }"
|
||||
class="fc-post-card__assoc-link"
|
||||
>
|
||||
<v-icon icon="mdi-link-variant" size="x-small" />
|
||||
{{ a.role === 'announces'
|
||||
? 'The full set is in Discord' : 'Announced on Patreon' }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Off-platform file-host links (mega/gdrive/…) found in the body.
|
||||
Shown once expanded; clickable now, auto-downloaded by the worker
|
||||
slice. -->
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<ImportFiltersForm />
|
||||
<TranslationCard />
|
||||
<DiscordGroupingCard />
|
||||
<PostAssociationsCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<MaintenanceTile
|
||||
icon="mdi-link-variant"
|
||||
title="Announcement links"
|
||||
blurb="Pair a Patreon teaser with the Discord drop it announced."
|
||||
>
|
||||
<div class="text-caption fc-muted mb-3">
|
||||
Some creators post a cropped fragment on Patreon to say the real thing has
|
||||
landed in their Discord. FC proposes those pairs; nothing is linked until
|
||||
you accept one. A wrong link would tell you two different pieces are the
|
||||
same, so this stays a suggestion.
|
||||
</div>
|
||||
|
||||
<v-switch
|
||||
v-model="enabled" color="accent" hide-details density="compact"
|
||||
label="Look for announcement pairs"
|
||||
@update:model-value="store.setEnabled"
|
||||
/>
|
||||
|
||||
<v-row class="mt-2">
|
||||
<v-col cols="12" sm="6">
|
||||
<SettingNumberField
|
||||
v-model="threshold" label="Confidence needed"
|
||||
:min="0" :max="1" :step="0.05"
|
||||
density="comfortable" max-width="none"
|
||||
:disabled="!enabled" @change="store.setThreshold(Number(threshold))"
|
||||
/>
|
||||
<div class="text-caption fc-muted mt-1">
|
||||
A pair always needs <strong>two</strong> reasons — being close in time
|
||||
and the post itself mentioning Discord. Neither is enough alone at any
|
||||
setting at or above 0.55, which is what stops a busy posting day from
|
||||
producing false pairs.
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<SettingNumberField
|
||||
v-model="windowHours" label="How far apart (hours)"
|
||||
:min="1" :step="1"
|
||||
density="comfortable" max-width="none"
|
||||
:disabled="!enabled" @change="store.setWindowHours(Number(windowHours))"
|
||||
/>
|
||||
<div class="text-caption fc-muted mt-1">
|
||||
The announcement exists in order to point at the drop, so the two are
|
||||
usually minutes to hours apart.
|
||||
</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
|
||||
</strong>
|
||||
<v-spacer />
|
||||
<v-btn size="small" variant="text" :loading="store.loading" @click="store.rescan">
|
||||
Scan now
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.proposals.length" class="text-caption fc-muted">
|
||||
Nothing proposed. That is the expected state most of the time — pairs only
|
||||
appear when a post both lands near a drop and says it is about Discord.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="p in store.proposals" :key="p.id"
|
||||
class="fc-assoc"
|
||||
>
|
||||
<div class="fc-assoc__body">
|
||||
<RouterLink :to="{ name: 'latest', query: { post_id: p.announcement_post_id } }">
|
||||
post {{ p.announcement_post_id }}
|
||||
</RouterLink>
|
||||
<span class="fc-assoc__arrow">announced</span>
|
||||
<RouterLink :to="{ name: 'latest', query: { post_id: p.payload_post_id } }">
|
||||
drop {{ p.payload_post_id }}
|
||||
</RouterLink>
|
||||
<!-- The per-signal breakdown, not just the total: "why did it suggest
|
||||
this" is the question the operator actually has, and a lone score
|
||||
cannot answer it. -->
|
||||
<div class="text-caption fc-muted">
|
||||
{{ Math.round(p.score * 100) }}% ·
|
||||
timing {{ Math.round((p.signals?.proximity ?? 0) * 100) }}% ·
|
||||
says so {{ Math.round((p.signals?.declared ?? 0) * 100) }}%
|
||||
</div>
|
||||
</div>
|
||||
<v-btn size="small" variant="tonal" @click="store.accept(p.id)">Link</v-btn>
|
||||
<v-btn size="small" variant="text" @click="store.dismiss(p.id)">Dismiss</v-btn>
|
||||
</div>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { usePostAssociationsStore } from '../../stores/postAssociations.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import SettingNumberField from '../common/SettingNumberField.vue'
|
||||
|
||||
const store = usePostAssociationsStore()
|
||||
const enabled = ref(true)
|
||||
const threshold = ref(0.6)
|
||||
const windowHours = ref(24)
|
||||
|
||||
watch(() => store.enabled, (v) => { enabled.value = v }, { immediate: true })
|
||||
watch(() => store.threshold, (v) => { threshold.value = v }, { immediate: true })
|
||||
watch(() => store.windowHours, (v) => { windowHours.value = v }, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
// Both swallow their own failures: a settings read that fails should not
|
||||
// leave the queue unrendered, and vice versa.
|
||||
await Promise.allSettled([store.loadSettings(), store.load()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-assoc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
.fc-assoc__body { flex: 1; min-width: 0; }
|
||||
.fc-assoc__arrow {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
margin: 0 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 } } },
|
||||
|
||||
Reference in New Issue
Block a user