feat(post-modal): PostModal — full Patreon-style v-dialog (header + image grid + sanitized body + attachments); reads from usePostModalStore
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
|||||||
|
<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>
|
||||||
Reference in New Issue
Block a user