feat(posts): images open the modal; only text expands in place
Post cards no longer expand the whole card on click (the old two-click path
to the images). The card is compact-only now:
- Hero / rail thumbs / the +N tile are buttons that open the post-scoped image
modal (modal.open(id, { postImageIds })) so you view big + arrow through ALL
the post's images. The feed caps thumbnails at 6, so for posts with more we
lazily getPostFull to get the complete id list; +N opens at the first hidden
image.
- The description is the ONLY in-place expansion: a Show more / Show less toggle
shown only when the text is actually truncated (server description_truncated
flag OR a measured CSS-clamp overflow, ResizeObserver-guarded). Expanding
loads description_full when server-truncated and renders it unclamped.
- Attachments: download chips now render inline in the compact card (the feed
already carries download_url), since the expanded view is gone.
Removes PostImageGrid.vue (the mosaic, now unused). Tests cover show-more
visibility + image-click opening the scoped modal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,29 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card
|
<v-card class="fc-post-card" variant="outlined">
|
||||||
:class="['fc-post-card', expanded && 'fc-post-card--expanded']"
|
|
||||||
variant="outlined"
|
|
||||||
:tabindex="expanded ? -1 : 0"
|
|
||||||
@click="onCardClick"
|
|
||||||
@keydown.enter="onCardClick"
|
|
||||||
>
|
|
||||||
<div class="fc-post-card__head">
|
<div class="fc-post-card__head">
|
||||||
<!-- Posts with no live subscription have source=null (alembic
|
<!-- Posts with no live subscription have source=null (alembic 0030);
|
||||||
0030); show a "filesystem import" affordance instead of a
|
show a "filesystem import" affordance instead of a platform chip. -->
|
||||||
platform chip. -->
|
|
||||||
<v-chip size="x-small" variant="tonal">
|
<v-chip size="x-small" variant="tonal">
|
||||||
{{ post.source?.platform ?? 'filesystem import' }}
|
{{ post.source?.platform ?? 'filesystem import' }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
|
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
|
||||||
class="fc-post-card__artist"
|
class="fc-post-card__artist"
|
||||||
@click.stop
|
|
||||||
>{{ post.artist.name }}</RouterLink>
|
>{{ post.artist.name }}</RouterLink>
|
||||||
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
|
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
|
||||||
<span v-if="expanded && images.length" class="fc-post-card__meta">
|
<span v-if="totalImages" class="fc-post-card__meta">
|
||||||
· {{ images.length }} image{{ images.length === 1 ? '' : 's' }}
|
· {{ totalImages }} image{{ totalImages === 1 ? '' : 's' }}
|
||||||
</span>
|
|
||||||
<span v-if="expanded && attachments.length" class="fc-post-card__meta">
|
|
||||||
· {{ attachments.length }} attachment{{ attachments.length === 1 ? '' : 's' }}
|
|
||||||
</span>
|
</span>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -31,140 +20,108 @@
|
|||||||
:href="post.post_url" target="_blank" rel="noopener"
|
:href="post.post_url" target="_blank" rel="noopener"
|
||||||
icon="mdi-open-in-new" size="x-small" variant="text"
|
icon="mdi-open-in-new" size="x-small" variant="text"
|
||||||
:aria-label="`open original post on ${post.source?.platform ?? 'web'}`"
|
:aria-label="`open original post on ${post.source?.platform ?? 'web'}`"
|
||||||
@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>
|
||||||
|
|
||||||
<!-- Compact body: collapsed card. Hero + thumb rail + truncated text. -->
|
<div class="fc-post-card__body">
|
||||||
<div v-if="!expanded" class="fc-post-card__body">
|
|
||||||
<div class="fc-post-card__media">
|
<div class="fc-post-card__media">
|
||||||
<template v-if="images.length">
|
<template v-if="images.length">
|
||||||
<div class="fc-post-card__hero">
|
<!-- Images open the post-scoped image modal (look bigger + arrow
|
||||||
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" />
|
through ALL the post's images) — the card never expands. -->
|
||||||
</div>
|
<button
|
||||||
<div v-if="rail.length" class="fc-post-card__rail">
|
type="button" class="fc-post-card__hero"
|
||||||
<div v-for="t in rail" :key="t.image_id" class="fc-post-card__rail-cell">
|
aria-label="Open images" @click="openModal(hero.image_id)"
|
||||||
<img :src="t.thumbnail_url" :alt="`thumbnail`" loading="lazy" />
|
>
|
||||||
</div>
|
<img :src="hero.thumbnail_url" alt="hero thumbnail" loading="lazy" />
|
||||||
<div v-if="moreCount > 0" class="fc-post-card__rail-more">
|
</button>
|
||||||
+{{ moreCount }}
|
<div v-if="rail.length || moreCount" class="fc-post-card__rail">
|
||||||
</div>
|
<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)"
|
||||||
|
>
|
||||||
|
<img :src="t.thumbnail_url" alt="thumbnail" loading="lazy" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="moreCount > 0" type="button"
|
||||||
|
class="fc-post-card__rail-more"
|
||||||
|
:aria-label="`Open ${moreCount} more images`"
|
||||||
|
@click="openModalAtMore"
|
||||||
|
>+{{ moreCount }}</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<PostEmptyThumbs v-else />
|
<PostEmptyThumbs v-else />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fc-post-card__text">
|
<div class="fc-post-card__text">
|
||||||
<h3 v-if="plainTitle" class="fc-post-card__title">
|
<h3 v-if="plainTitle" class="fc-post-card__title">{{ plainTitle }}</h3>
|
||||||
{{ plainTitle }}
|
|
||||||
</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 }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p v-if="post.description_plain" class="fc-post-card__desc">
|
<p
|
||||||
{{ post.description_plain }}
|
v-if="hasDescription" ref="descEl"
|
||||||
</p>
|
class="fc-post-card__desc"
|
||||||
|
:class="{ 'fc-post-card__desc--clamped': !descExpanded }"
|
||||||
|
>{{ descText }}</p>
|
||||||
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
|
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
|
||||||
(no description)
|
(no description)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="post.attachments?.length" class="fc-post-card__atts">
|
<!-- The ONLY in-place expansion: the post text, and only when it's
|
||||||
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
|
actually truncated (server flag or a CSS-clamp overflow). -->
|
||||||
{{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }}
|
<button
|
||||||
</div>
|
v-if="canExpand" type="button" class="fc-post-card__more"
|
||||||
</div>
|
@click="toggleDesc"
|
||||||
</div>
|
>{{ descExpanded ? 'Show less' : 'Show more' }}</button>
|
||||||
|
|
||||||
<!-- Expanded body: title, full mosaic, full sanitized HTML description,
|
<div v-if="attachments.length" class="fc-post-card__atts">
|
||||||
attachments. Lazy-loaded detail via getPostFull. -->
|
|
||||||
<div v-else class="fc-post-card__expanded">
|
|
||||||
<h2 v-if="plainTitle" class="fc-post-card__title-full">
|
|
||||||
{{ plainTitle }}
|
|
||||||
</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
|
<a
|
||||||
v-for="att in attachments" :key="att.id"
|
v-for="att in attachments" :key="att.id"
|
||||||
:href="att.download_url" download
|
:href="att.download_url" download class="fc-post-card__att"
|
||||||
class="fc-post-card__att"
|
|
||||||
@click.stop
|
|
||||||
>
|
>
|
||||||
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
|
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
|
||||||
<span>{{ att.original_filename }}</span>
|
<span>{{ att.original_filename }}</span>
|
||||||
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
|
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import { useModalStore } from '../../stores/modal.js'
|
||||||
import { usePostsStore } from '../../stores/posts.js'
|
import { usePostsStore } from '../../stores/posts.js'
|
||||||
import { sanitizeHtml, toPlainText } from '../../utils/htmlSanitize.js'
|
import { toPlainText } from '../../utils/htmlSanitize.js'
|
||||||
import PostEmptyThumbs from './PostEmptyThumbs.vue'
|
import PostEmptyThumbs from './PostEmptyThumbs.vue'
|
||||||
import PostImageGrid from './PostImageGrid.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
post: { type: Object, required: true },
|
post: { type: Object, required: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const postsStore = usePostsStore()
|
const postsStore = usePostsStore()
|
||||||
|
const modal = useModalStore()
|
||||||
|
|
||||||
// Per-card expand state. No global modal — each PostCard owns its own
|
// Full detail (uncapped thumbnails + full description), fetched lazily — only
|
||||||
// view-mode and lazy-loaded detail.
|
// when opening the modal for a post with >6 images, or expanding a
|
||||||
const expanded = ref(false)
|
// server-truncated description.
|
||||||
const detail = ref(null)
|
const detail = ref(null)
|
||||||
const detailLoaded = ref(false)
|
|
||||||
const detailError = ref(null)
|
|
||||||
|
|
||||||
// When expanded + detail loaded, prefer the uncapped detail thumbnails +
|
const attachments = computed(() => props.post.attachments || [])
|
||||||
// full description. Falls back to feed shape if detail fetch is in flight
|
const images = computed(() => props.post.thumbnails || [])
|
||||||
// or failed.
|
const totalImages = computed(() => images.value.length + (props.post.thumbnails_more || 0))
|
||||||
const merged = computed(() => detail.value || props.post)
|
|
||||||
const images = computed(() => merged.value.thumbnails || [])
|
|
||||||
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))
|
const plainTitle = computed(() => toPlainText(props.post.post_title))
|
||||||
|
|
||||||
// Compact-view hero+rail derived from the feed-shape (capped 6).
|
const hero = computed(() => images.value[0])
|
||||||
const hero = computed(() => props.post.thumbnails?.[0])
|
const rail = computed(() => images.value.slice(1, 4))
|
||||||
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4))
|
const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
|
||||||
const moreCount = computed(() => {
|
const moreCount = computed(() => {
|
||||||
const more = props.post.thumbnails_more || 0
|
const more = props.post.thumbnails_more || 0
|
||||||
const railLen = rail.value.length
|
const extraShown = Math.max(0, images.value.length - visibleCount.value)
|
||||||
const extraShown = Math.max(0, (props.post.thumbnails?.length || 0) - 1 - railLen)
|
|
||||||
return more + extraShown
|
return more + extraShown
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -180,49 +137,77 @@ const relativeDate = computed(() => {
|
|||||||
return new Date(sortDateIso.value).toLocaleDateString()
|
return new Date(sortDateIso.value).toLocaleDateString()
|
||||||
})
|
})
|
||||||
|
|
||||||
const descriptionHtml = computed(() => {
|
// --- images → post-scoped modal ---------------------------------------
|
||||||
// Detail endpoint returns description_full as plain text (the service
|
async function fullImageIds () {
|
||||||
// uses html_to_plain on the stored description). Render plain text in
|
// Feed caps thumbnails at 6; load detail for the complete id list only when
|
||||||
// <p> wrappers; sanitize defensively in case the backend ever returns
|
// there are more, so the modal can arrow through ALL of the post's images.
|
||||||
// raw HTML.
|
if ((props.post.thumbnails_more || 0) === 0) {
|
||||||
const raw = merged.value.description_full || merged.value.description_plain
|
return images.value.map((t) => t.image_id)
|
||||||
if (!raw) return ''
|
|
||||||
if (/[<>]/.test(raw)) return sanitizeHtml(raw)
|
|
||||||
const esc = raw
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
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.
|
|
||||||
}
|
}
|
||||||
|
if (!detail.value) {
|
||||||
|
try {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExpanded () {
|
async function openModal (imageId) {
|
||||||
expanded.value = !expanded.value
|
modal.open(imageId, { postImageIds: await fullImageIds() })
|
||||||
if (expanded.value) loadDetailIfNeeded()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCardClick (e) {
|
async function openModalAtMore () {
|
||||||
// Inner interactive elements use @click.stop so they never reach here.
|
const ids = await fullImageIds()
|
||||||
// Whole-card click expands a collapsed card; collapsing is chevron-only
|
const first = ids[visibleCount.value] ?? ids[0]
|
||||||
// so a mosaic-image click on an expanded card can never accidentally
|
if (first != null) modal.open(first, { postImageIds: ids })
|
||||||
// collapse the surrounding card.
|
}
|
||||||
if (expanded.value) return
|
|
||||||
expanded.value = true
|
// --- description "Show more" (text-only, in place, only when truncated) ----
|
||||||
loadDetailIfNeeded()
|
const descExpanded = ref(false)
|
||||||
|
const cssOverflow = ref(false)
|
||||||
|
const descEl = ref(null)
|
||||||
|
|
||||||
|
const hasDescription = computed(() => !!props.post.description_plain)
|
||||||
|
const fullDescription = computed(() => detail.value?.description_full || null)
|
||||||
|
const descText = computed(() =>
|
||||||
|
descExpanded.value
|
||||||
|
? (fullDescription.value || props.post.description_plain)
|
||||||
|
: props.post.description_plain,
|
||||||
|
)
|
||||||
|
// Show the toggle iff the server truncated the text OR the clamp is cutting it.
|
||||||
|
const canExpand = computed(
|
||||||
|
() => props.post.description_truncated === true || cssOverflow.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
function measureOverflow () {
|
||||||
|
const el = descEl.value
|
||||||
|
cssOverflow.value = !!el && el.scrollHeight > el.clientHeight + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let ro = null
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(measureOverflow)
|
||||||
|
// Re-measure when the card resizes (the container-query clamp differs by
|
||||||
|
// width). Guarded for happy-dom / older runtimes without ResizeObserver.
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && descEl.value) {
|
||||||
|
ro = new ResizeObserver(() => { if (!descExpanded.value) measureOverflow() })
|
||||||
|
ro.observe(descEl.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => { if (ro) { ro.disconnect(); ro = null } })
|
||||||
|
|
||||||
|
async function toggleDesc () {
|
||||||
|
if (!descExpanded.value) {
|
||||||
|
if (props.post.description_truncated && !fullDescription.value && !detail.value) {
|
||||||
|
try {
|
||||||
|
detail.value = await postsStore.getPostFull(props.post.id)
|
||||||
|
} catch { /* render the truncated text rather than nothing */ }
|
||||||
|
}
|
||||||
|
descExpanded.value = true
|
||||||
|
} else {
|
||||||
|
descExpanded.value = false
|
||||||
|
nextTick(measureOverflow)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes (n) {
|
function formatBytes (n) {
|
||||||
@@ -239,20 +224,6 @@ function formatBytes (n) {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
transition: border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
.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 {
|
.fc-post-card__head {
|
||||||
@@ -273,7 +244,6 @@ function formatBytes (n) {
|
|||||||
.fc-post-card__date,
|
.fc-post-card__date,
|
||||||
.fc-post-card__meta { white-space: nowrap; }
|
.fc-post-card__meta { white-space: nowrap; }
|
||||||
|
|
||||||
/* ---- COMPACT BODY ---- */
|
|
||||||
.fc-post-card__body {
|
.fc-post-card__body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -288,6 +258,13 @@ function formatBytes (n) {
|
|||||||
.fc-post-card__text { flex: 1 1 0; min-width: 0; }
|
.fc-post-card__text { flex: 1 1 0; min-width: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image tiles are buttons (open the post-scoped modal) — reset button chrome. */
|
||||||
|
.fc-post-card__hero,
|
||||||
|
.fc-post-card__rail-cell,
|
||||||
|
.fc-post-card__rail-more {
|
||||||
|
display: block; padding: 0; border: 0; background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.fc-post-card__hero {
|
.fc-post-card__hero {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 10;
|
aspect-ratio: 16 / 10;
|
||||||
@@ -297,10 +274,13 @@ function formatBytes (n) {
|
|||||||
.fc-post-card__hero img {
|
.fc-post-card__hero img {
|
||||||
width: 100%; height: 100%;
|
width: 100%; height: 100%;
|
||||||
object-fit: cover; display: block;
|
object-fit: cover; display: block;
|
||||||
|
transition: transform 0.2s ease, filter 0.2s ease;
|
||||||
}
|
}
|
||||||
|
.fc-post-card__hero:hover img,
|
||||||
|
.fc-post-card__rail-cell:hover img { transform: scale(1.03); filter: brightness(1.08); }
|
||||||
|
|
||||||
.fc-post-card__rail {
|
.fc-post-card__rail {
|
||||||
display: flex; gap: 6px; margin-top: 6px;
|
display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.fc-post-card__rail-cell {
|
.fc-post-card__rail-cell {
|
||||||
width: 80px; height: 80px;
|
width: 80px; height: 80px;
|
||||||
@@ -318,6 +298,10 @@ function formatBytes (n) {
|
|||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
.fc-post-card__rail-more:hover {
|
||||||
|
border-color: rgb(var(--v-theme-accent));
|
||||||
|
color: rgb(var(--v-theme-accent));
|
||||||
|
}
|
||||||
|
|
||||||
.fc-post-card__title {
|
.fc-post-card__title {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
@@ -346,67 +330,36 @@ function formatBytes (n) {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
margin: 0 0 12px 0;
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
/* Clamp ONLY while collapsed; expanding drops the clamp to show it all. */
|
||||||
|
.fc-post-card__desc--clamped {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
@container (min-width: 800px) {
|
||||||
|
.fc-post-card__desc--clamped { -webkit-line-clamp: 5; }
|
||||||
|
}
|
||||||
.fc-post-card__desc--missing {
|
.fc-post-card__desc--missing {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
}
|
}
|
||||||
@container (min-width: 800px) {
|
|
||||||
.fc-post-card__desc { -webkit-line-clamp: 5; }
|
.fc-post-card__more {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 0;
|
||||||
|
background: none; border: 0; cursor: pointer;
|
||||||
|
color: rgb(var(--v-theme-accent));
|
||||||
|
font-size: 0.85rem; font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.fc-post-card__more:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.fc-post-card__atts {
|
.fc-post-card__atts {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
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: 700;
|
|
||||||
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;
|
display: flex; flex-wrap: wrap; gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
.fc-post-card__att {
|
.fc-post-card__att {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -423,5 +376,6 @@ function formatBytes (n) {
|
|||||||
color: rgb(var(--v-theme-accent));
|
color: rgb(var(--v-theme-accent));
|
||||||
border-color: rgb(var(--v-theme-accent));
|
border-color: rgb(var(--v-theme-accent));
|
||||||
}
|
}
|
||||||
|
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
|
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="fc-post-grid">
|
|
||||||
<button
|
|
||||||
v-for="(t, idx) in thumbnails"
|
|
||||||
:key="t.image_id"
|
|
||||||
type="button"
|
|
||||||
class="fc-post-grid__cell"
|
|
||||||
:aria-label="`Open image ${idx + 1} of ${thumbnails.length}`"
|
|
||||||
@click="openImage(t.image_id, idx)"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="t.thumbnail_url"
|
|
||||||
:alt="`thumbnail ${idx + 1}`"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useModalStore } from '../../stores/modal.js'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
thumbnails: { type: Array, required: true }, // [{ image_id, thumbnail_url, ... }]
|
|
||||||
})
|
|
||||||
|
|
||||||
const modal = useModalStore()
|
|
||||||
|
|
||||||
const imageIds = computed(() => props.thumbnails.map(t => t.image_id))
|
|
||||||
|
|
||||||
function openImage (id, idx) {
|
|
||||||
modal.open(id, { postImageIds: imageIds.value, initialIndex: idx })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.fc-post-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.fc-post-grid__cell {
|
|
||||||
aspect-ratio: 4 / 3;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: rgb(var(--v-theme-background));
|
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
}
|
|
||||||
.fc-post-grid__cell:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
box-shadow: 0 0 0 2px rgb(var(--v-theme-accent));
|
|
||||||
}
|
|
||||||
.fc-post-grid__cell img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -18,9 +18,9 @@ export const useModalStore = defineStore('modal', () => {
|
|||||||
const inflight = useInflightToken()
|
const inflight = useInflightToken()
|
||||||
|
|
||||||
// Post-scoped cycle. When set, prev/next cycles within this array
|
// Post-scoped cycle. When set, prev/next cycles within this array
|
||||||
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When
|
// (used by PostCard image clicks — the modal is scoped to that post's
|
||||||
// null, prev/next falls back to current.value.neighbors (the
|
// images). When null, prev/next falls back to current.value.neighbors
|
||||||
// gallery-store-driven /api/gallery/image/<id> neighbors).
|
// (the gallery-store-driven /api/gallery/image/<id> neighbors).
|
||||||
const postImageIds = ref(null)
|
const postImageIds = ref(null)
|
||||||
const postImageIndex = ref(0)
|
const postImageIndex = ref(0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,61 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
|
||||||
import PostCard from '../../src/components/posts/PostCard.vue'
|
import PostCard from '../../src/components/posts/PostCard.vue'
|
||||||
|
import { useModalStore } from '../../src/stores/modal.js'
|
||||||
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const BASE = {
|
||||||
|
id: 1, external_post_id: 'P1',
|
||||||
|
post_title: '<strong>Hello World</strong>',
|
||||||
|
post_url: 'https://x/1', post_date: now, downloaded_at: now,
|
||||||
|
artist: { id: 1, name: 'Sabu', slug: 'sabu' },
|
||||||
|
source: { id: 1, platform: 'subscribestar' },
|
||||||
|
thumbnails: [], thumbnails_more: 0, attachments: [],
|
||||||
|
}
|
||||||
|
|
||||||
describe('PostCard', () => {
|
describe('PostCard', () => {
|
||||||
it('renders the HTML-stripped title and the artist', () => {
|
it('renders the HTML-stripped title and the artist', () => {
|
||||||
const pinia = freshPinia()
|
const post = { ...BASE, description_plain: 'a description' }
|
||||||
const now = new Date().toISOString()
|
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
|
||||||
const post = {
|
|
||||||
id: 1, external_post_id: 'P1',
|
|
||||||
post_title: '<strong>Hello World</strong>',
|
|
||||||
post_url: 'https://x/1', post_date: now, downloaded_at: now,
|
|
||||||
description_plain: 'a description',
|
|
||||||
artist: { id: 1, name: 'Sabu', slug: 'sabu' },
|
|
||||||
source: { id: 1, platform: 'subscribestar' },
|
|
||||||
thumbnails: [], thumbnails_more: 0, attachments: [],
|
|
||||||
}
|
|
||||||
const w = mountComponent(PostCard, { props: { post }, pinia })
|
|
||||||
const t = w.text()
|
const t = w.text()
|
||||||
expect(t).toContain('Hello World') // plain title
|
expect(t).toContain('Hello World') // plain title
|
||||||
expect(t).not.toContain('<strong>') // tags stripped
|
expect(t).not.toContain('<strong>') // tags stripped
|
||||||
expect(t).toContain('Sabu') // artist (RouterLink slot)
|
expect(t).toContain('Sabu') // artist (RouterLink slot)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows a "Show more" toggle only when the description is truncated', () => {
|
||||||
|
const truncated = mountComponent(PostCard, {
|
||||||
|
props: { post: { ...BASE, description_plain: 'lots of text…', description_truncated: true } },
|
||||||
|
pinia: freshPinia(),
|
||||||
|
})
|
||||||
|
expect(truncated.text()).toContain('Show more')
|
||||||
|
|
||||||
|
const full = mountComponent(PostCard, {
|
||||||
|
props: { post: { ...BASE, description_plain: 'short', description_truncated: false } },
|
||||||
|
pinia: freshPinia(),
|
||||||
|
})
|
||||||
|
expect(full.text()).not.toContain('Show more')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking an image opens the post-scoped modal (never expands the card)', async () => {
|
||||||
|
const pinia = freshPinia()
|
||||||
|
const modal = useModalStore()
|
||||||
|
const openSpy = vi.spyOn(modal, 'open').mockResolvedValue()
|
||||||
|
const post = {
|
||||||
|
...BASE,
|
||||||
|
description_plain: 'd',
|
||||||
|
thumbnails: [
|
||||||
|
{ image_id: 10, thumbnail_url: '/a' },
|
||||||
|
{ image_id: 11, thumbnail_url: '/b' },
|
||||||
|
],
|
||||||
|
thumbnails_more: 0,
|
||||||
|
}
|
||||||
|
const w = mountComponent(PostCard, { props: { post }, pinia })
|
||||||
|
await w.find('.fc-post-card__hero').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(10, { postImageIds: [10, 11] })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user