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 } } },