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
129 lines
4.7 KiB
Vue
129 lines
4.7 KiB
Vue
<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>
|