feat(posts): plain bold titles; reserve View-original to the post card; provenance links to the posts feed
- Post titles arrived as stored HTML (e.g. <strong>…</strong>) rendering as literal markup. New toPlainText() strips tags; titles render plain + bold (ProvenancePanel and PostCard). - Removed "View original post" from the provenance panel (modal) — the open-original button lives on the PostCard (the post view). - Provenance "View post" now navigates to the /posts feed (post_id query), not the gallery image grid. (Feed in-context landing lands next.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@
|
|||||||
<span v-if="postDate(e)" class="fc-prov__date">{{ postDate(e) }}</span>
|
<span v-if="postDate(e)" class="fc-prov__date">{{ postDate(e) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="fc-prov__post">
|
<div class="fc-prov__post">
|
||||||
{{ e.post.title || `Post ${e.post.external_post_id}` }}
|
{{ postTitle(e) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="fc-prov__meta">
|
<div class="fc-prov__meta">
|
||||||
<RouterLink :to="`/artist/${e.artist.slug}`">
|
<RouterLink :to="`/artist/${e.artist.slug}`">
|
||||||
@@ -30,12 +30,8 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="fc-prov__actions">
|
<div class="fc-prov__actions">
|
||||||
<a
|
|
||||||
v-if="e.post.url" :href="e.post.url"
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
>↗ View original post</a>
|
|
||||||
<a href="#" @click.prevent="openPost(e.post.id)">
|
<a href="#" @click.prevent="openPost(e.post.id)">
|
||||||
View images from this post
|
View post
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
v-if="e.post.description_html"
|
v-if="e.post.description_html"
|
||||||
@@ -83,6 +79,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useModalStore } from '../../stores/modal.js'
|
import { useModalStore } from '../../stores/modal.js'
|
||||||
import { useProvenanceStore } from '../../stores/provenance.js'
|
import { useProvenanceStore } from '../../stores/provenance.js'
|
||||||
import { formatPostDate } from '../../utils/date.js'
|
import { formatPostDate } from '../../utils/date.js'
|
||||||
|
import { toPlainText } from '../../utils/htmlSanitize.js'
|
||||||
|
|
||||||
const modal = useModalStore()
|
const modal = useModalStore()
|
||||||
const prov = useProvenanceStore()
|
const prov = useProvenanceStore()
|
||||||
@@ -134,9 +131,16 @@ const show = computed(() => {
|
|||||||
const attachments = computed(() => state.value?.attachments || [])
|
const attachments = computed(() => state.value?.attachments || [])
|
||||||
|
|
||||||
function postDate(e) { return formatPostDate(e.post.date) }
|
function postDate(e) { return formatPostDate(e.post.date) }
|
||||||
|
function postTitle(e) {
|
||||||
|
// Titles can arrive as stored HTML (e.g. "<strong>…</strong>"); render
|
||||||
|
// as plain text (the CSS makes it bold).
|
||||||
|
return toPlainText(e.post.title) || `Post ${e.post.external_post_id}`
|
||||||
|
}
|
||||||
|
|
||||||
function openPost(postId) {
|
function openPost(postId) {
|
||||||
router.push({ path: '/gallery', query: { post_id: postId } })
|
// Land on the post in the posts feed (in context), not the gallery
|
||||||
|
// image grid. Operator-flagged 2026-05-28.
|
||||||
|
router.push({ path: '/posts', query: { post_id: postId } })
|
||||||
modal.close()
|
modal.close()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -159,7 +163,7 @@ function openPost(postId) {
|
|||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
}
|
}
|
||||||
.fc-prov__post {
|
.fc-prov__post {
|
||||||
font-weight: 600; margin: 4px 0;
|
font-weight: 700; margin: 4px 0;
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
}
|
}
|
||||||
.fc-prov__meta {
|
.fc-prov__meta {
|
||||||
|
|||||||
@@ -56,8 +56,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fc-post-card__text">
|
<div class="fc-post-card__text">
|
||||||
<h3 v-if="post.post_title" class="fc-post-card__title">
|
<h3 v-if="plainTitle" class="fc-post-card__title">
|
||||||
{{ post.post_title }}
|
{{ plainTitle }}
|
||||||
</h3>
|
</h3>
|
||||||
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
||||||
Post {{ post.external_post_id }}
|
Post {{ post.external_post_id }}
|
||||||
@@ -80,8 +80,8 @@
|
|||||||
<!-- Expanded body: title, full mosaic, full sanitized HTML description,
|
<!-- Expanded body: title, full mosaic, full sanitized HTML description,
|
||||||
attachments. Lazy-loaded detail via getPostFull. -->
|
attachments. Lazy-loaded detail via getPostFull. -->
|
||||||
<div v-else class="fc-post-card__expanded">
|
<div v-else class="fc-post-card__expanded">
|
||||||
<h2 v-if="post.post_title" class="fc-post-card__title-full">
|
<h2 v-if="plainTitle" class="fc-post-card__title-full">
|
||||||
{{ post.post_title }}
|
{{ plainTitle }}
|
||||||
</h2>
|
</h2>
|
||||||
<h2 v-else class="fc-post-card__title-full fc-post-card__title--missing">
|
<h2 v-else class="fc-post-card__title-full fc-post-card__title--missing">
|
||||||
Post {{ post.external_post_id }}
|
Post {{ post.external_post_id }}
|
||||||
@@ -125,7 +125,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
import { usePostsStore } from '../../stores/posts.js'
|
import { usePostsStore } from '../../stores/posts.js'
|
||||||
import { sanitizeHtml } from '../../utils/htmlSanitize.js'
|
import { sanitizeHtml, toPlainText } from '../../utils/htmlSanitize.js'
|
||||||
import PostEmptyThumbs from './PostEmptyThumbs.vue'
|
import PostEmptyThumbs from './PostEmptyThumbs.vue'
|
||||||
import PostImageGrid from './PostImageGrid.vue'
|
import PostImageGrid from './PostImageGrid.vue'
|
||||||
|
|
||||||
@@ -149,6 +149,10 @@ const merged = computed(() => detail.value || props.post)
|
|||||||
const images = computed(() => merged.value.thumbnails || [])
|
const images = computed(() => merged.value.thumbnails || [])
|
||||||
const attachments = computed(() => merged.value.attachments || [])
|
const attachments = computed(() => merged.value.attachments || [])
|
||||||
|
|
||||||
|
// Titles can arrive as stored HTML (e.g. "<strong>…</strong>"); render as
|
||||||
|
// plain text — the CSS makes the title bold.
|
||||||
|
const plainTitle = computed(() => toPlainText(props.post.post_title))
|
||||||
|
|
||||||
// Compact-view hero+rail derived from the feed-shape (capped 6).
|
// Compact-view hero+rail derived from the feed-shape (capped 6).
|
||||||
const hero = computed(() => props.post.thumbnails?.[0])
|
const hero = computed(() => props.post.thumbnails?.[0])
|
||||||
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4))
|
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4))
|
||||||
@@ -312,7 +316,7 @@ function formatBytes (n) {
|
|||||||
|
|
||||||
.fc-post-card__title {
|
.fc-post-card__title {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
font-size: 18px; font-weight: 500;
|
font-size: 18px; font-weight: 700;
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@@ -369,7 +373,7 @@ function formatBytes (n) {
|
|||||||
.fc-post-card__title-full {
|
.fc-post-card__title-full {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 500;
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ export function sanitizeHtml (html) {
|
|||||||
return doc.body.innerHTML
|
return doc.body.innerHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip all tags + decode entities to plain text. Used for titles that
|
||||||
|
// arrive as stored HTML (e.g. "<strong>Edelgard at the Sex-Arcade</strong>")
|
||||||
|
// which must render as text, not literal markup. DOMParser yields an inert
|
||||||
|
// document (no script execution, no resource loads), so reading textContent
|
||||||
|
// is safe.
|
||||||
|
export function toPlainText (html) {
|
||||||
|
if (typeof html !== 'string' || !html) return ''
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||||
|
return (doc.body.textContent || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
function _scrubNode (node) {
|
function _scrubNode (node) {
|
||||||
const children = Array.from(node.children)
|
const children = Array.from(node.children)
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
|
|||||||
Reference in New Issue
Block a user