Release: dev → main (first public release) #258

Merged
bvandeusen merged 94 commits from dev into main 2026-09-25 10:02:40 -04:00
5 changed files with 328 additions and 9 deletions
Showing only changes of commit 7aa074d1a9 - Show all commits
+146 -6
View File
@@ -30,6 +30,12 @@
<span v-if="totalImages" class="fc-post-card__meta">
· {{ totalImages }} image{{ totalImages === 1 ? '' : 's' }}
</span>
<!-- #4402. Referenced images are counted apart from the post's own: a
Patreon card must not read as though the creator posted Discord's
files on Patreon. -->
<span v-if="refImages.length" class="fc-post-card__meta fc-post-card__ref-meta">
· +{{ refImages.length }} from Discord
</span>
<!-- Only on a grouping that has actually grown. An ordinary post can
never show this, and a grouping that has not grown says nothing —
an "updated" label that is always present teaches you to ignore it. -->
@@ -66,9 +72,16 @@
<button
v-for="t in rail" :key="t.image_id" type="button"
class="fc-post-card__rail-cell"
aria-label="Open image" @click="openModal(t.image_id)"
:class="{ 'fc-post-card__rail-cell--ref': t.role }"
:title="t.role ? refTitle(t) : undefined"
:aria-label="t.role ? `Open ${refTitle(t)}` : 'Open image'"
@click="openModal(t.image_id)"
>
<img :src="t.thumbnail_url" alt="thumbnail" loading="lazy" />
<v-icon
v-if="t.role" icon="mdi-link-variant" size="x-small"
class="fc-post-card__ref-badge"
/>
</button>
<button
v-if="moreCount > 0" type="button"
@@ -138,9 +151,40 @@
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">
<!-- #4402: the unified card. A teaser is a pointer, so the card shows
what it points at — the drop's images join the rail above and
its text sits here — by REFERENCE: the Discord posts keep their
own place in the feed, and nothing here moves them. A link FC made
by itself says so and offers the undo the operator asked for. -->
<div v-if="unified" class="fc-post-card__unified">
<div
v-for="l in unified.links" :key="l.association_id"
class="fc-post-card__unified-head"
>
<v-icon icon="mdi-link-variant" size="x-small" />
<RouterLink
:to="{ query: { post_id: l.post_id } }"
class="fc-post-card__assoc-link"
>{{ linkLabel(l) }}</RouterLink>
<button
type="button" class="fc-post-card__undo"
:disabled="unlinking === l.association_id"
@click="undoLink(l)"
>{{ l.linked_by === 'fc' ? 'Undo' : 'Unlink' }}</button>
</div>
<div
v-for="t in unified.texts" :key="t.post_id"
class="fc-post-card__unified-text"
>
<div class="fc-post-card__unified-meta">
Discord · {{ shortDate(t.date) }}{{ t.role === 'variant' ? ' · variant' : '' }}
</div>
<p class="fc-post-card__unified-body">{{ t.text }}</p>
</div>
</div>
<div v-if="plainLinks.length" class="fc-post-card__assoc">
<RouterLink
v-for="a in associations" :key="a.id"
v-for="a in plainLinks" :key="a.id"
:to="{ query: { post_id: a.post_id } }"
class="fc-post-card__assoc-link"
>
@@ -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;
@@ -61,6 +61,47 @@
</v-col>
</v-row>
<!-- #4402. What a link DOES once made: the teaser's card shows the drop
and the piece's other variants by reference. Nothing is moved — these
two windows only decide what the card shows and which duplicate the
feed leaves out. -->
<div class="text-body-2 mt-4 mb-1"><strong>On the teaser's card</strong></div>
<div class="text-caption fc-muted mb-2">
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.
</div>
<v-row>
<v-col cols="12" sm="6">
<SettingNumberField
v-model="foldHours" label="Hide the drop's own card within (hours)"
:min="0" :step="1"
density="comfortable" max-width="none"
:disabled="!enabled" @change="store.setFoldHours(Number(foldHours))"
/>
<div class="text-caption fc-muted mt-1">
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.
</div>
</v-col>
<v-col cols="12" sm="6">
<SettingNumberField
v-model="familyDays" label="Reach back for variants (days)"
:min="0" :step="1"
density="comfortable" max-width="none"
:disabled="!enabled" @change="store.setFamilyDays(Number(familyDays))"
/>
<div class="text-caption fc-muted mt-1">
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.
</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
@@ -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
+18 -2
View File
@@ -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
}
})
+30 -1
View File
@@ -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,
}
})
+89
View File
@@ -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')
})
})