feat(post-card): responsive redesign — container-query split (stack <800px / side-by-side ≥800px), hero + thumb rail, +N overflow chip, line-clamp body (3 narrow / 5 wide), title/desc fallbacks for sparse data, click→postModal.open
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card class="fc-post-card" variant="outlined">
|
<v-card
|
||||||
|
class="fc-post-card"
|
||||||
|
variant="outlined"
|
||||||
|
tabindex="0"
|
||||||
|
@click="onCardClick"
|
||||||
|
@keydown.enter="onCardClick"
|
||||||
|
>
|
||||||
<div class="fc-post-card__head">
|
<div class="fc-post-card__head">
|
||||||
<v-chip size="x-small" variant="tonal">{{ post.source.platform }}</v-chip>
|
<v-chip size="x-small" variant="tonal">{{ post.source.platform }}</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>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
@@ -13,72 +20,74 @@
|
|||||||
: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}`"
|
:aria-label="`open original post on ${post.source.platform}`"
|
||||||
|
@click.stop
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 v-if="post.post_title" class="fc-post-card__title">{{ post.post_title }}</h3>
|
<div class="fc-post-card__body">
|
||||||
|
<div class="fc-post-card__media">
|
||||||
|
<template v-if="post.thumbnails?.length">
|
||||||
|
<div class="fc-post-card__hero">
|
||||||
|
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div v-if="rail.length" class="fc-post-card__rail">
|
||||||
|
<div v-for="t in rail" :key="t.image_id" class="fc-post-card__rail-cell">
|
||||||
|
<img :src="t.thumbnail_url" :alt="`thumbnail`" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div v-if="moreCount > 0" class="fc-post-card__rail-more">
|
||||||
|
+{{ moreCount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<PostEmptyThumbs v-else />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="descriptionToShow" class="fc-post-card__desc">
|
<div class="fc-post-card__text">
|
||||||
<span class="fc-post-card__desc-text">{{ descriptionToShow }}</span>
|
<h3 v-if="post.post_title" class="fc-post-card__title">
|
||||||
<button
|
{{ post.post_title }}
|
||||||
v-if="post.description_truncated && !expanded"
|
</h3>
|
||||||
class="fc-post-card__more" type="button"
|
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
||||||
@click="expand"
|
Post {{ post.external_post_id }}
|
||||||
>Show more</button>
|
</h3>
|
||||||
<button
|
|
||||||
v-if="expanded"
|
|
||||||
class="fc-post-card__more" type="button"
|
|
||||||
@click="expanded = false"
|
|
||||||
>Show less</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="post.thumbnails.length" class="fc-post-card__thumbs">
|
<p v-if="post.description_plain" class="fc-post-card__desc">
|
||||||
<RouterLink
|
{{ post.description_plain }}
|
||||||
v-for="t in post.thumbnails"
|
</p>
|
||||||
:key="t.image_id"
|
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
|
||||||
:to="{ path: '/gallery', query: { post_id: post.id } }"
|
(no description)
|
||||||
class="fc-post-card__thumb"
|
</p>
|
||||||
>
|
|
||||||
<v-img
|
|
||||||
:src="t.thumbnail_url" cover :alt="`thumbnail`"
|
|
||||||
width="96" height="96" class="fc-post-card__thumb-img"
|
|
||||||
/>
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
v-if="post.thumbnails_more > 0"
|
|
||||||
:to="{ path: '/gallery', query: { post_id: post.id } }"
|
|
||||||
class="fc-post-card__more-thumbs"
|
|
||||||
>+{{ post.thumbnails_more }} more</RouterLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="post.attachments.length" class="fc-post-card__attachments">
|
<div v-if="post.attachments?.length" class="fc-post-card__atts">
|
||||||
<a
|
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
|
||||||
v-for="att in post.attachments"
|
{{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }}
|
||||||
:key="att.id"
|
</div>
|
||||||
:href="att.download_url"
|
</div>
|
||||||
download
|
|
||||||
class="fc-post-card__att"
|
|
||||||
>
|
|
||||||
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
|
|
||||||
<span class="fc-post-card__att-name">{{ att.original_filename }}</span>
|
|
||||||
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { usePostsStore } from '../../stores/posts.js'
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import { usePostModalStore } from '../../stores/postModal.js'
|
||||||
|
import PostEmptyThumbs from './PostEmptyThumbs.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
post: { type: Object, required: true },
|
post: { type: Object, required: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const postsStore = usePostsStore()
|
const postModal = usePostModalStore()
|
||||||
|
|
||||||
const expanded = ref(false)
|
const hero = computed(() => props.post.thumbnails?.[0])
|
||||||
const fullDescription = ref(null)
|
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
|
||||||
|
})
|
||||||
|
|
||||||
const sortDateIso = computed(() => props.post.post_date || props.post.downloaded_at)
|
const sortDateIso = computed(() => props.post.post_date || props.post.downloaded_at)
|
||||||
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
|
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
|
||||||
@@ -92,25 +101,8 @@ const relativeDate = computed(() => {
|
|||||||
return new Date(sortDateIso.value).toLocaleDateString()
|
return new Date(sortDateIso.value).toLocaleDateString()
|
||||||
})
|
})
|
||||||
|
|
||||||
const descriptionToShow = computed(() => {
|
function onCardClick () {
|
||||||
if (expanded.value && fullDescription.value) return fullDescription.value
|
postModal.open(props.post)
|
||||||
return props.post.description_plain
|
|
||||||
})
|
|
||||||
|
|
||||||
async function expand() {
|
|
||||||
if (!fullDescription.value) {
|
|
||||||
const detail = await postsStore.getPostFull(props.post.id)
|
|
||||||
fullDescription.value = detail?.description_full || props.post.description_plain
|
|
||||||
}
|
|
||||||
expanded.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
@@ -118,13 +110,25 @@ function formatBytes(n) {
|
|||||||
.fc-post-card {
|
.fc-post-card {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
container-type: inline-size;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
.fc-post-card: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__head {
|
.fc-post-card__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.fc-post-card__artist {
|
.fc-post-card__artist {
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
@@ -133,72 +137,118 @@ function formatBytes(n) {
|
|||||||
}
|
}
|
||||||
.fc-post-card__artist:hover { color: rgb(var(--v-theme-accent)); }
|
.fc-post-card__artist:hover { color: rgb(var(--v-theme-accent)); }
|
||||||
.fc-post-card__date { white-space: nowrap; }
|
.fc-post-card__date { white-space: nowrap; }
|
||||||
.fc-post-card__title {
|
|
||||||
font-size: 1.05rem;
|
.fc-post-card__body {
|
||||||
margin: 0.6rem 0 0.4rem;
|
|
||||||
}
|
|
||||||
.fc-post-card__desc {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
color: rgb(var(--v-theme-on-surface));
|
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
}
|
|
||||||
.fc-post-card__more {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: rgb(var(--v-theme-accent));
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
font-size: inherit;
|
|
||||||
}
|
|
||||||
.fc-post-card__thumbs {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 16px;
|
||||||
align-items: center;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
}
|
||||||
.fc-post-card__thumb { line-height: 0; }
|
@container (min-width: 800px) {
|
||||||
.fc-post-card__thumb-img {
|
.fc-post-card__body {
|
||||||
border-radius: 4px;
|
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__hero {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
.fc-post-card__more-thumbs {
|
.fc-post-card__hero img {
|
||||||
display: inline-flex;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-post-card__rail {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.fc-post-card__rail-cell {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.fc-post-card__rail-cell img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.fc-post-card__rail-more {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 96px;
|
|
||||||
height: 96px;
|
|
||||||
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
|
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.fc-post-card__more-thumbs:hover {
|
|
||||||
color: rgb(var(--v-theme-accent));
|
.fc-post-card__title {
|
||||||
border-color: rgb(var(--v-theme-accent));
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.fc-post-card__attachments {
|
.fc-post-card__title--missing {
|
||||||
display: flex;
|
font-style: italic;
|
||||||
flex-wrap: wrap;
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
gap: 0.4rem;
|
|
||||||
margin-top: 0.6rem;
|
|
||||||
}
|
}
|
||||||
.fc-post-card__att {
|
@container (min-width: 800px) {
|
||||||
|
.fc-post-card__title {
|
||||||
|
font-size: 20px;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-post-card__desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fc-post-card__desc--missing {
|
||||||
|
font-style: italic;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
|
@container (min-width: 800px) {
|
||||||
|
.fc-post-card__desc {
|
||||||
|
-webkit-line-clamp: 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-post-card__atts {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 6px;
|
||||||
padding: 0.2rem 0.5rem;
|
font-size: 0.85rem;
|
||||||
border: 1px solid rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
border-radius: 999px;
|
|
||||||
color: rgb(var(--v-theme-on-surface));
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
}
|
||||||
.fc-post-card__att:hover {
|
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user