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:
@@ -4,7 +4,6 @@
|
|||||||
<RouterView />
|
<RouterView />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
<ImageViewer v-if="modal.isOpen" @close="modal.close()" />
|
<ImageViewer v-if="modal.isOpen" @close="modal.close()" />
|
||||||
<PostModal />
|
|
||||||
<AppSnackbar ref="snackbar" />
|
<AppSnackbar ref="snackbar" />
|
||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
@@ -14,7 +13,6 @@ import { onMounted, ref } from 'vue'
|
|||||||
import AppShell from './components/AppShell.vue'
|
import AppShell from './components/AppShell.vue'
|
||||||
import AppSnackbar from './components/AppSnackbar.vue'
|
import AppSnackbar from './components/AppSnackbar.vue'
|
||||||
import ImageViewer from './components/modal/ImageViewer.vue'
|
import ImageViewer from './components/modal/ImageViewer.vue'
|
||||||
import PostModal from './components/posts/PostModal.vue'
|
|
||||||
import { useModalStore } from './stores/modal.js'
|
import { useModalStore } from './stores/modal.js'
|
||||||
|
|
||||||
const modal = useModalStore()
|
const modal = useModalStore()
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card
|
<v-card
|
||||||
class="fc-post-card"
|
:class="['fc-post-card', expanded && 'fc-post-card--expanded']"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
tabindex="0"
|
:tabindex="expanded ? -1 : 0"
|
||||||
@click="onCardClick"
|
@click="onCardClick"
|
||||||
@keydown.enter="onCardClick"
|
@keydown.enter="onCardClick"
|
||||||
>
|
>
|
||||||
@@ -14,6 +14,12 @@
|
|||||||
@click.stop
|
@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">
|
||||||
|
· {{ 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-spacer />
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="post.post_url"
|
v-if="post.post_url"
|
||||||
@@ -22,11 +28,18 @@
|
|||||||
:aria-label="`open original post on ${post.source.platform}`"
|
:aria-label="`open original post on ${post.source.platform}`"
|
||||||
@click.stop
|
@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>
|
||||||
|
|
||||||
<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">
|
<div class="fc-post-card__media">
|
||||||
<template v-if="post.thumbnails?.length">
|
<template v-if="images.length">
|
||||||
<div class="fc-post-card__hero">
|
<div class="fc-post-card__hero">
|
||||||
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" />
|
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
@@ -63,28 +76,85 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
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 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 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 hero = computed(() => props.post.thumbnails?.[0])
|
||||||
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4))
|
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4))
|
||||||
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 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)
|
const extraShown = Math.max(0, (props.post.thumbnails?.length || 0) - 1 - railLen)
|
||||||
return more + extraShown
|
return more + extraShown
|
||||||
})
|
})
|
||||||
@@ -101,8 +171,57 @@ const relativeDate = computed(() => {
|
|||||||
return new Date(sortDateIso.value).toLocaleDateString()
|
return new Date(sortDateIso.value).toLocaleDateString()
|
||||||
})
|
})
|
||||||
|
|
||||||
function onCardClick () {
|
const descriptionHtml = computed(() => {
|
||||||
postModal.open(props.post)
|
// 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, '&')
|
||||||
|
.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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -110,17 +229,22 @@ function onCardClick () {
|
|||||||
.fc-post-card {
|
.fc-post-card {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
cursor: pointer;
|
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
transition: border-color 0.15s ease;
|
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));
|
border-color: rgb(var(--v-theme-accent));
|
||||||
}
|
}
|
||||||
.fc-post-card:focus-visible {
|
.fc-post-card:focus-visible {
|
||||||
outline: 2px solid rgb(var(--v-theme-accent));
|
outline: 2px solid rgb(var(--v-theme-accent));
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
.fc-post-card--expanded {
|
||||||
|
border-color: rgb(var(--v-theme-accent) / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
.fc-post-card__head {
|
.fc-post-card__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -129,6 +253,7 @@ function onCardClick () {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.fc-post-card__artist {
|
.fc-post-card__artist {
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
@@ -136,8 +261,10 @@ function onCardClick () {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.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,
|
||||||
|
.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;
|
||||||
@@ -148,13 +275,8 @@ function onCardClick () {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
.fc-post-card__media {
|
.fc-post-card__media { flex: 0 0 50%; }
|
||||||
flex: 0 0 50%;
|
.fc-post-card__text { flex: 1 1 0; min-width: 0; }
|
||||||
}
|
|
||||||
.fc-post-card__text {
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-post-card__hero {
|
.fc-post-card__hero {
|
||||||
@@ -164,35 +286,24 @@ function onCardClick () {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
.fc-post-card__hero img {
|
.fc-post-card__hero img {
|
||||||
width: 100%;
|
width: 100%; height: 100%;
|
||||||
height: 100%;
|
object-fit: cover; display: block;
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-post-card__rail {
|
.fc-post-card__rail {
|
||||||
display: flex;
|
display: flex; gap: 6px; margin-top: 6px;
|
||||||
gap: 6px;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
}
|
||||||
.fc-post-card__rail-cell {
|
.fc-post-card__rail-cell {
|
||||||
width: 80px;
|
width: 80px; height: 80px;
|
||||||
height: 80px;
|
overflow: hidden; border-radius: 4px;
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
.fc-post-card__rail-cell img {
|
.fc-post-card__rail-cell img {
|
||||||
width: 100%;
|
width: 100%; height: 100%;
|
||||||
height: 100%;
|
object-fit: cover; display: block;
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
.fc-post-card__rail-more {
|
.fc-post-card__rail-more {
|
||||||
width: 80px;
|
width: 80px; height: 80px;
|
||||||
height: 80px;
|
display: flex; align-items: center; justify-content: center;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
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));
|
||||||
@@ -201,8 +312,7 @@ function onCardClick () {
|
|||||||
|
|
||||||
.fc-post-card__title {
|
.fc-post-card__title {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
font-size: 18px;
|
font-size: 18px; font-weight: 500;
|
||||||
font-weight: 500;
|
|
||||||
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;
|
||||||
@@ -238,9 +348,7 @@ function onCardClick () {
|
|||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
}
|
}
|
||||||
@container (min-width: 800px) {
|
@container (min-width: 800px) {
|
||||||
.fc-post-card__desc {
|
.fc-post-card__desc { -webkit-line-clamp: 5; }
|
||||||
-webkit-line-clamp: 5;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc-post-card__atts {
|
.fc-post-card__atts {
|
||||||
@@ -251,4 +359,60 @@ function onCardClick () {
|
|||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
}
|
}
|
||||||
.fc-post-card__att-icon { 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>
|
</style>
|
||||||
|
|||||||
@@ -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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
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>
|
|
||||||
@@ -11,9 +11,9 @@ export const useModalStore = defineStore('modal', () => {
|
|||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
|
||||||
// Post-scoped cycle. When set, prev/next cycles within this array
|
// Post-scoped cycle. When set, prev/next cycles within this array
|
||||||
// (used by PostModal's PostImageGrid clicks). When null, prev/next
|
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When
|
||||||
// falls back to current.value.neighbors (the gallery-store-driven
|
// null, prev/next falls back to current.value.neighbors (the
|
||||||
// /api/gallery/image/<id> neighbors).
|
// 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,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 }
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user