feat(post-card): absorb PostModal into PostCard with click-to-expand

PostCard and PostModal competed for the same data and rendered redundant
chrome (header twice, image grid twice, attachment list twice). The wider
PostCard layout we shipped 2026-05-27 has enough real estate to be the
canonical post surface, so collapse the two into one.

Compact (default) state is unchanged: hero + 3-cell rail + truncated
title + 3/5-line description + attachment count badge. Whole-card click
expands in place. Expanded state shows: full title, mosaic of ALL post
images via PostImageGrid (uncapped, lazy-loaded via getPostFull), full
sanitized-HTML description with paragraph wrapping, attachments as
downloadable pill links. Click the chevron in the header to collapse;
mosaic image clicks open ImageViewer scoped to the post (modalStore's
postImageIds path is preserved — only the comment changed).

Per-card local state — no global modal store. Each PostCard owns its
own expanded ref and lazy-loaded detail; collapsing a card discards
neither (so re-expand is instant after the first fetch).

Deleted: PostModal.vue, postModal.js store. Removed the App.vue mount.
This commit is contained in:
2026-05-27 14:30:04 -04:00
parent b8ad17c68d
commit 4d2c464045
5 changed files with 212 additions and 300 deletions
-2
View File
@@ -4,7 +4,6 @@
<RouterView />
</AppShell>
<ImageViewer v-if="modal.isOpen" @close="modal.close()" />
<PostModal />
<AppSnackbar ref="snackbar" />
</v-app>
</template>
@@ -14,7 +13,6 @@ import { onMounted, ref } from 'vue'
import AppShell from './components/AppShell.vue'
import AppSnackbar from './components/AppSnackbar.vue'
import ImageViewer from './components/modal/ImageViewer.vue'
import PostModal from './components/posts/PostModal.vue'
import { useModalStore } from './stores/modal.js'
const modal = useModalStore()
+209 -45
View File
@@ -1,8 +1,8 @@
<template>
<v-card
class="fc-post-card"
:class="['fc-post-card', expanded && 'fc-post-card--expanded']"
variant="outlined"
tabindex="0"
:tabindex="expanded ? -1 : 0"
@click="onCardClick"
@keydown.enter="onCardClick"
>
@@ -14,6 +14,12 @@
@click.stop
>{{ post.artist.name }}</RouterLink>
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
<span v-if="expanded && images.length" class="fc-post-card__meta">
· {{ images.length }} image{{ images.length === 1 ? '' : 's' }}
</span>
<span v-if="expanded && attachments.length" class="fc-post-card__meta">
· {{ attachments.length }} attachment{{ attachments.length === 1 ? '' : 's' }}
</span>
<v-spacer />
<v-btn
v-if="post.post_url"
@@ -22,11 +28,18 @@
:aria-label="`open original post on ${post.source.platform}`"
@click.stop
/>
<v-btn
:icon="expanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
size="x-small" variant="text"
:aria-label="expanded ? 'Collapse post' : 'Expand post'"
@click.stop="toggleExpanded"
/>
</div>
<div class="fc-post-card__body">
<!-- Compact body: collapsed card. Hero + thumb rail + truncated text. -->
<div v-if="!expanded" class="fc-post-card__body">
<div class="fc-post-card__media">
<template v-if="post.thumbnails?.length">
<template v-if="images.length">
<div class="fc-post-card__hero">
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" />
</div>
@@ -63,28 +76,85 @@
</div>
</div>
</div>
<!-- Expanded body: title, full mosaic, full sanitized HTML description,
attachments. Lazy-loaded detail via getPostFull. -->
<div v-else class="fc-post-card__expanded">
<h2 v-if="post.post_title" class="fc-post-card__title-full">
{{ post.post_title }}
</h2>
<h2 v-else class="fc-post-card__title-full fc-post-card__title--missing">
Post {{ post.external_post_id }}
</h2>
<section v-if="images.length" class="fc-post-card__sec">
<PostImageGrid :thumbnails="images" />
<div v-if="!detailLoaded" class="fc-post-card__loading-hint">
Loading full image list
</div>
</section>
<section v-if="descriptionHtml" class="fc-post-card__sec">
<div class="fc-post-card__desc-full" v-html="descriptionHtml" />
</section>
<section v-else-if="detailLoaded" class="fc-post-card__sec">
<p class="fc-post-card__desc fc-post-card__desc--missing">(no description)</p>
</section>
<section v-if="attachments.length" class="fc-post-card__sec">
<h3 class="fc-post-card__h3">Attachments</h3>
<div class="fc-post-card__atts-full">
<a
v-for="att in attachments" :key="att.id"
:href="att.download_url" download
class="fc-post-card__att"
@click.stop
>
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
<span>{{ att.original_filename }}</span>
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
</a>
</div>
</section>
</div>
</v-card>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { usePostModalStore } from '../../stores/postModal.js'
import { usePostsStore } from '../../stores/posts.js'
import { sanitizeHtml } from '../../utils/htmlSanitize.js'
import PostEmptyThumbs from './PostEmptyThumbs.vue'
import PostImageGrid from './PostImageGrid.vue'
const props = defineProps({
post: { type: Object, required: true },
})
const postModal = usePostModalStore()
const postsStore = usePostsStore()
// Per-card expand state. No global modal — each PostCard owns its own
// view-mode and lazy-loaded detail.
const expanded = ref(false)
const detail = ref(null)
const detailLoaded = ref(false)
const detailError = ref(null)
// When expanded + detail loaded, prefer the uncapped detail thumbnails +
// full description. Falls back to feed shape if detail fetch is in flight
// or failed.
const merged = computed(() => detail.value || props.post)
const images = computed(() => merged.value.thumbnails || [])
const attachments = computed(() => merged.value.attachments || [])
// Compact-view hero+rail derived from the feed-shape (capped 6).
const hero = computed(() => props.post.thumbnails?.[0])
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4))
const moreCount = computed(() => {
const more = props.post.thumbnails_more || 0
const railLen = rail.value.length
// If feed returned more than (1 hero + 3 rail = 4), extra spills into "+N".
const extraShown = Math.max(0, (props.post.thumbnails?.length || 0) - 1 - railLen)
return more + extraShown
})
@@ -101,8 +171,57 @@ const relativeDate = computed(() => {
return new Date(sortDateIso.value).toLocaleDateString()
})
function onCardClick () {
postModal.open(props.post)
const descriptionHtml = computed(() => {
// Detail endpoint returns description_full as plain text (the service
// uses html_to_plain on the stored description). Render plain text in
// <p> wrappers; sanitize defensively in case the backend ever returns
// raw HTML.
const raw = merged.value.description_full || merged.value.description_plain
if (!raw) return ''
if (/[<>]/.test(raw)) return sanitizeHtml(raw)
const esc = raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
return esc
.split(/\n\s*\n/)
.map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
})
async function loadDetailIfNeeded () {
if (detailLoaded.value || detail.value) return
try {
detail.value = await postsStore.getPostFull(props.post.id)
detailLoaded.value = true
} catch (e) {
detailError.value = e.message
// Leave merged on feed-shape; the card still renders the truncated
// body so the operator isn't staring at a blank panel.
}
}
function toggleExpanded () {
expanded.value = !expanded.value
if (expanded.value) loadDetailIfNeeded()
}
function onCardClick (e) {
// Inner interactive elements use @click.stop so they never reach here.
// Whole-card click expands a collapsed card; collapsing is chevron-only
// so a mosaic-image click on an expanded card can never accidentally
// collapse the surrounding card.
if (expanded.value) return
expanded.value = true
loadDetailIfNeeded()
}
function formatBytes (n) {
if (!n) return '0 B'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
}
</script>
@@ -110,17 +229,22 @@ function onCardClick () {
.fc-post-card {
padding: 1rem;
margin-bottom: 1rem;
cursor: pointer;
container-type: inline-size;
transition: border-color 0.15s ease;
}
.fc-post-card:hover {
.fc-post-card:not(.fc-post-card--expanded) {
cursor: pointer;
}
.fc-post-card:not(.fc-post-card--expanded):hover {
border-color: rgb(var(--v-theme-accent));
}
.fc-post-card:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 2px;
}
.fc-post-card--expanded {
border-color: rgb(var(--v-theme-accent) / 0.6);
}
.fc-post-card__head {
display: flex;
@@ -129,6 +253,7 @@ function onCardClick () {
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 12px;
flex-wrap: wrap;
}
.fc-post-card__artist {
color: rgb(var(--v-theme-on-surface));
@@ -136,8 +261,10 @@ function onCardClick () {
font-weight: 600;
}
.fc-post-card__artist:hover { color: rgb(var(--v-theme-accent)); }
.fc-post-card__date { white-space: nowrap; }
.fc-post-card__date,
.fc-post-card__meta { white-space: nowrap; }
/* ---- COMPACT BODY ---- */
.fc-post-card__body {
display: flex;
flex-direction: column;
@@ -148,13 +275,8 @@ function onCardClick () {
flex-direction: row;
gap: 24px;
}
.fc-post-card__media {
flex: 0 0 50%;
}
.fc-post-card__text {
flex: 1 1 0;
min-width: 0;
}
.fc-post-card__media { flex: 0 0 50%; }
.fc-post-card__text { flex: 1 1 0; min-width: 0; }
}
.fc-post-card__hero {
@@ -164,35 +286,24 @@ function onCardClick () {
border-radius: 6px;
}
.fc-post-card__hero img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
width: 100%; height: 100%;
object-fit: cover; display: block;
}
.fc-post-card__rail {
display: flex;
gap: 6px;
margin-top: 6px;
display: flex; gap: 6px; margin-top: 6px;
}
.fc-post-card__rail-cell {
width: 80px;
height: 80px;
overflow: hidden;
border-radius: 4px;
width: 80px; height: 80px;
overflow: hidden; border-radius: 4px;
}
.fc-post-card__rail-cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
width: 100%; height: 100%;
object-fit: cover; display: block;
}
.fc-post-card__rail-more {
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
width: 80px; height: 80px;
display: flex; align-items: center; justify-content: center;
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
border-radius: 4px;
color: rgb(var(--v-theme-on-surface-variant));
@@ -201,8 +312,7 @@ function onCardClick () {
.fc-post-card__title {
font-family: 'Fraunces', Georgia, serif;
font-size: 18px;
font-weight: 500;
font-size: 18px; font-weight: 500;
margin: 0 0 8px 0;
color: rgb(var(--v-theme-on-surface));
display: -webkit-box;
@@ -238,9 +348,7 @@ function onCardClick () {
color: rgb(var(--v-theme-on-surface-variant));
}
@container (min-width: 800px) {
.fc-post-card__desc {
-webkit-line-clamp: 5;
}
.fc-post-card__desc { -webkit-line-clamp: 5; }
}
.fc-post-card__atts {
@@ -251,4 +359,60 @@ function onCardClick () {
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
/* ---- EXPANDED BODY ---- */
.fc-post-card__expanded {
display: flex;
flex-direction: column;
gap: 20px;
}
.fc-post-card__title-full {
font-family: 'Fraunces', Georgia, serif;
font-size: 22px;
font-weight: 500;
margin: 0;
color: rgb(var(--v-theme-on-surface));
}
@container (min-width: 800px) {
.fc-post-card__title-full { font-size: 26px; }
}
.fc-post-card__sec { margin: 0; }
.fc-post-card__h3 {
font-family: 'Fraunces', Georgia, serif;
font-size: 16px;
font-weight: 500;
margin: 0 0 8px 0;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-card__loading-hint {
margin-top: 8px;
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__desc-full {
font-size: 0.95rem;
line-height: 1.55;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-card__desc-full :deep(p) { margin: 0 0 12px 0; }
.fc-post-card__desc-full :deep(a) { color: rgb(var(--v-theme-accent)); }
.fc-post-card__atts-full {
display: flex; flex-wrap: wrap; gap: 8px;
}
.fc-post-card__att {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid rgb(var(--v-theme-on-surface-variant));
border-radius: 999px;
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
font-size: 0.85rem;
}
.fc-post-card__att:hover {
color: rgb(var(--v-theme-accent));
border-color: rgb(var(--v-theme-accent));
}
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
</style>
-211
View File
@@ -1,211 +0,0 @@
<template>
<v-dialog
:model-value="store.isOpen"
max-width="1100"
scrollable
@update:model-value="(v) => { if (!v) close() }"
>
<v-card v-if="store.currentPost" class="fc-post-modal">
<div class="fc-post-modal__head">
<v-chip size="x-small" variant="tonal">{{ post.source.platform }}</v-chip>
<RouterLink
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
class="fc-post-modal__artist"
@click="close"
>{{ post.artist.name }}</RouterLink>
<span class="fc-post-modal__date" :title="absoluteDate">
{{ relativeDate }}
</span>
<span v-if="post.thumbnails?.length" class="fc-post-modal__meta">
· {{ post.thumbnails.length }} image{{ post.thumbnails.length === 1 ? '' : 's' }}
</span>
<span v-if="post.attachments?.length" class="fc-post-modal__meta">
· {{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }}
</span>
<v-spacer />
<v-btn
v-if="post.post_url"
:href="post.post_url" target="_blank" rel="noopener"
icon="mdi-open-in-new" size="small" variant="text"
:aria-label="`open original post on ${post.source.platform}`"
/>
<v-btn
icon="mdi-close" size="small" variant="text"
aria-label="Close"
@click="close"
/>
</div>
<v-card-text class="fc-post-modal__body">
<section v-if="post.thumbnails?.length" class="fc-post-modal__sec">
<PostImageGrid :thumbnails="post.thumbnails" />
<div v-if="!store.detailLoaded" class="fc-post-modal__loading-hint">
Loading full image list…
</div>
</section>
<section v-if="post.post_title" class="fc-post-modal__sec">
<h2 class="fc-post-modal__title">{{ post.post_title }}</h2>
</section>
<section v-if="descriptionHtml" class="fc-post-modal__sec">
<div class="fc-post-modal__desc" v-html="descriptionHtml" />
</section>
<section v-if="post.attachments?.length" class="fc-post-modal__sec">
<h3 class="fc-post-modal__h3">Attachments</h3>
<div class="fc-post-modal__atts">
<a
v-for="att in post.attachments" :key="att.id"
:href="att.download_url" download
class="fc-post-modal__att"
>
<v-icon size="small" class="fc-post-modal__att-icon">mdi-paperclip</v-icon>
<span class="fc-post-modal__att-name">{{ att.original_filename }}</span>
<span class="fc-post-modal__att-size">({{ formatBytes(att.size_bytes) }})</span>
</a>
</div>
</section>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { usePostModalStore } from '../../stores/postModal.js'
import { sanitizeHtml } from '../../utils/htmlSanitize.js'
import PostImageGrid from './PostImageGrid.vue'
const store = usePostModalStore()
const post = computed(() => store.currentPost || {})
const sortDateIso = computed(() => post.value.post_date || post.value.downloaded_at)
const absoluteDate = computed(() => sortDateIso.value
? new Date(sortDateIso.value).toLocaleString()
: '')
const relativeDate = computed(() => {
if (!sortDateIso.value) return ''
const then = new Date(sortDateIso.value).getTime()
const diff = (Date.now() - then) / 1000
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 86400 * 30) return `${Math.floor(diff / 86400)}d ago`
return new Date(sortDateIso.value).toLocaleDateString()
})
const descriptionHtml = computed(() => {
// Detail endpoint returns description_full as plain text (the
// existing service uses html_to_plain on the stored description).
// Render the plain text in <p> wrappers; the sanitizer below is
// defensive for the case where the backend ever switches to raw HTML.
const raw = post.value.description_full || post.value.description_plain
if (!raw) return ''
if (/[<>]/.test(raw)) return sanitizeHtml(raw)
const esc = raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
return esc
.split(/\n\s*\n/)
.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
})
function close () {
store.close()
}
function formatBytes (n) {
if (!n) return '0 B'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
}
</script>
<style scoped>
.fc-post-modal {
background: rgb(var(--v-theme-surface));
max-height: 90vh;
display: flex;
flex-direction: column;
}
.fc-post-modal__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
font-size: 0.85rem;
color: rgb(var(--v-theme-on-surface-variant));
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.15);
}
.fc-post-modal__artist {
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
font-weight: 600;
}
.fc-post-modal__artist:hover { color: rgb(var(--v-theme-accent)); }
.fc-post-modal__date { white-space: nowrap; }
.fc-post-modal__meta { white-space: nowrap; }
.fc-post-modal__body {
padding: 16px;
overflow-y: auto;
flex: 1 1 auto;
}
.fc-post-modal__sec {
margin-bottom: 24px;
}
.fc-post-modal__sec:last-child { margin-bottom: 0; }
.fc-post-modal__title {
font-family: 'Fraunces', Georgia, serif;
font-size: 24px;
font-weight: 500;
margin: 0;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-modal__h3 {
font-family: 'Fraunces', Georgia, serif;
font-size: 16px;
font-weight: 500;
margin: 0 0 8px 0;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-modal__desc {
font-size: 0.95rem;
line-height: 1.55;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-modal__desc :deep(p) { margin: 0 0 12px 0; }
.fc-post-modal__desc :deep(a) { color: rgb(var(--v-theme-accent)); }
.fc-post-modal__loading-hint {
margin-top: 8px;
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-modal__atts {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.fc-post-modal__att {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid rgb(var(--v-theme-on-surface-variant));
border-radius: 999px;
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
font-size: 0.85rem;
}
.fc-post-modal__att:hover {
color: rgb(var(--v-theme-accent));
border-color: rgb(var(--v-theme-accent));
}
.fc-post-modal__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
</style>
+3 -3
View File
@@ -11,9 +11,9 @@ export const useModalStore = defineStore('modal', () => {
const error = ref(null)
// Post-scoped cycle. When set, prev/next cycles within this array
// (used by PostModal's PostImageGrid clicks). When null, prev/next
// falls back to current.value.neighbors (the gallery-store-driven
// /api/gallery/image/<id> neighbors).
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When
// null, prev/next falls back to current.value.neighbors (the
// gallery-store-driven /api/gallery/image/<id> neighbors).
const postImageIds = ref(null)
const postImageIndex = ref(0)
-39
View File
@@ -1,39 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { usePostsStore } from './posts.js'
export const usePostModalStore = defineStore('postModal', () => {
const postsStore = usePostsStore()
// Feed-shape on open; upgraded to detail shape (full description +
// uncapped thumbnails) once getPostFull resolves.
const currentPost = ref(null)
const detailLoaded = ref(false)
const error = ref(null)
const isOpen = computed(() => currentPost.value !== null)
async function open (post) {
currentPost.value = post
detailLoaded.value = false
error.value = null
try {
const detail = await postsStore.getPostFull(post.id)
if (currentPost.value?.id === post.id) {
currentPost.value = detail
detailLoaded.value = true
}
} catch (e) {
// Keep feed-shape data; PostModal can still render title + truncated
// description + the up-to-6 feed thumbnails. Just surface the error.
error.value = e.message
}
}
function close () {
currentPost.value = null
detailLoaded.value = false
error.value = null
}
return { currentPost, detailLoaded, error, isOpen, open, close }
})