83c1745fd0
PostCard + modal ProvenancePanel show the English title/description by default when a translation exists, with a per-card "show original (<lang>)" toggle — translated bodies render as plain text, originals keep their sanitized HTML. New TranslationCard in Settings → Ingestion & filters: enable switch, Interpreter base URL (generic placeholder, no default host), target language, a reachability indicator + untranslated-posts count + "Translate now". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
581 lines
20 KiB
Vue
581 lines
20 KiB
Vue
<template>
|
|
<v-card class="fc-post-card" variant="outlined">
|
|
<div class="fc-post-card__head">
|
|
<!-- Posts with no live subscription have source=null (alembic 0030);
|
|
show a "filesystem import" affordance instead of a platform chip. -->
|
|
<v-chip size="x-small" variant="tonal">
|
|
{{ post.source?.platform ?? 'filesystem import' }}
|
|
</v-chip>
|
|
<RouterLink
|
|
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
|
|
class="fc-post-card__artist"
|
|
>{{ post.artist.name }}</RouterLink>
|
|
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
|
|
<span v-if="totalImages" class="fc-post-card__meta">
|
|
· {{ totalImages }} image{{ totalImages === 1 ? '' : 's' }}
|
|
</span>
|
|
<v-spacer />
|
|
<PostSeriesMenu :post="post" />
|
|
<v-btn
|
|
v-if="post.post_url"
|
|
:href="post.post_url" target="_blank" rel="noopener"
|
|
icon="mdi-open-in-new" size="x-small" variant="text"
|
|
:aria-label="`open original post on ${post.source?.platform ?? 'web'}`"
|
|
/>
|
|
</div>
|
|
|
|
<div class="fc-post-card__body">
|
|
<!-- Media column only when the post HAS images. Text-only posts (the bulk
|
|
of Patreon WIP/announcement/poll posts) let the body span the full
|
|
card width instead of padding a dead "no images" placeholder. -->
|
|
<div v-if="images.length" class="fc-post-card__media">
|
|
<!-- Images open the post-scoped image modal (look bigger + arrow
|
|
through ALL the post's images) — the card never expands. -->
|
|
<button
|
|
type="button" class="fc-post-card__hero"
|
|
aria-label="Open images" @click="openModal(hero.image_id)"
|
|
>
|
|
<img :src="hero.thumbnail_url" alt="hero thumbnail" loading="lazy" />
|
|
</button>
|
|
<div
|
|
v-if="rail.length || moreCount" class="fc-post-card__rail"
|
|
:style="{ '--fc-rail-cols': railCols }"
|
|
>
|
|
<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>
|
|
|
|
<div class="fc-post-card__text">
|
|
<h3 v-if="displayTitle" class="fc-post-card__title">{{ displayTitle }}</h3>
|
|
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
|
Post {{ post.external_post_id }}
|
|
</h3>
|
|
<!-- Translation toggle (#143): English shown by default; reveal the
|
|
original, labelled with its detected language. -->
|
|
<button
|
|
v-if="hasTranslation" type="button" class="fc-post-card__lang"
|
|
:title="showOriginal ? 'Show the English translation' : `Show the original${sourceLangLabel} text`"
|
|
@click.stop="showOriginal = !showOriginal"
|
|
>{{ showOriginal ? 'Show translation' : `Show original${sourceLangLabel}` }}</button>
|
|
|
|
<!-- Faithful (semantic) body render once expanded: backend-sanitized
|
|
HTML (headings, lists, links, inline images). Collapsed and the
|
|
no-detail fallback stay plain text. -->
|
|
<div
|
|
v-if="hasDescription && showHtmlBody"
|
|
class="fc-post-card__desc fc-post-card__desc--html"
|
|
v-html="descHtml"
|
|
/>
|
|
<p
|
|
v-else-if="hasDescription" ref="descEl"
|
|
class="fc-post-card__desc"
|
|
:class="{ 'fc-post-card__desc--clamped': !descExpanded }"
|
|
>{{ descTextDisplay }}</p>
|
|
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
|
|
(no description)
|
|
</p>
|
|
|
|
<!-- The ONLY in-place expansion: the post text, and only when it's
|
|
actually truncated (server flag or a CSS-clamp overflow). -->
|
|
<button
|
|
v-if="canExpand" type="button" class="fc-post-card__more"
|
|
@click="toggleDesc"
|
|
>{{ descExpanded ? 'Show less' : 'Show more' }}</button>
|
|
|
|
<!-- Off-platform file-host links (mega/gdrive/…) found in the body.
|
|
Shown once expanded; clickable now, auto-downloaded by the worker
|
|
slice. -->
|
|
<div v-if="descExpanded && externalLinks.length" class="fc-post-card__links">
|
|
<div class="fc-post-card__links-label">External files</div>
|
|
<a
|
|
v-for="lnk in externalLinks" :key="lnk.id"
|
|
:href="lnk.url" target="_blank" rel="noopener noreferrer"
|
|
class="fc-post-card__link"
|
|
>
|
|
<span class="fc-post-card__link-host">{{ lnk.host }}</span>
|
|
<span class="fc-post-card__link-text">{{ lnk.label || lnk.url }}</span>
|
|
<span
|
|
v-if="lnk.status && lnk.status !== 'pending'"
|
|
class="fc-post-card__link-status" :data-status="lnk.status"
|
|
>{{ lnk.status }}</span>
|
|
</a>
|
|
</div>
|
|
|
|
<div v-if="attachments.length" class="fc-post-card__atts">
|
|
<a
|
|
v-for="att in attachments" :key="att.id"
|
|
:href="att.download_url" download class="fc-post-card__att"
|
|
>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
import { RouterLink } from 'vue-router'
|
|
|
|
import { useModalStore } from '../../stores/modal.js'
|
|
import { usePostsStore } from '../../stores/posts.js'
|
|
import { toPlainText } from '../../utils/htmlSanitize.js'
|
|
import PostSeriesMenu from './PostSeriesMenu.vue'
|
|
|
|
const props = defineProps({
|
|
post: { type: Object, required: true },
|
|
})
|
|
|
|
const postsStore = usePostsStore()
|
|
const modal = useModalStore()
|
|
|
|
// Full detail (uncapped thumbnails + full description), fetched lazily — only
|
|
// when opening the modal for a post with >6 images, or expanding a
|
|
// server-truncated description.
|
|
const detail = ref(null)
|
|
|
|
const attachments = computed(() => props.post.attachments || [])
|
|
const images = computed(() => props.post.thumbnails || [])
|
|
const totalImages = computed(() => images.value.length + (props.post.thumbnails_more || 0))
|
|
const plainTitle = computed(() => toPlainText(props.post.post_title))
|
|
|
|
const hero = computed(() => images.value[0])
|
|
// The thumbnail strip spans the hero's full width (CSS grid, equal columns),
|
|
// rather than a fixed 3-cell cap. Show up to RAIL_MAX cells; when there are
|
|
// more images than fit, the last cell becomes a "+N" overflow tile so the
|
|
// count stays accurate.
|
|
const RAIL_MAX = 5
|
|
const serverMore = computed(() => props.post.thumbnails_more || 0)
|
|
const afterHero = computed(() => images.value.slice(1))
|
|
const hasOverflow = computed(
|
|
() => serverMore.value > 0 || afterHero.value.length > RAIL_MAX,
|
|
)
|
|
const rail = computed(() =>
|
|
hasOverflow.value
|
|
? afterHero.value.slice(0, RAIL_MAX - 1)
|
|
: afterHero.value.slice(0, RAIL_MAX),
|
|
)
|
|
const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
|
|
const moreCount = computed(() => {
|
|
if (!hasOverflow.value) return 0
|
|
return serverMore.value + Math.max(0, images.value.length - visibleCount.value)
|
|
})
|
|
// Columns = thumbnails shown plus the overflow tile, so the strip fills the
|
|
// hero's full width with equal cells.
|
|
const railCols = computed(() => rail.value.length + (moreCount.value > 0 ? 1 : 0))
|
|
|
|
const sortDateIso = computed(() => props.post.post_date || props.post.downloaded_at)
|
|
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
|
|
const relativeDate = computed(() => {
|
|
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()
|
|
})
|
|
|
|
// --- images → post-scoped modal ---------------------------------------
|
|
async function fullImageIds () {
|
|
// Feed caps thumbnails at 6; load detail for the complete id list only when
|
|
// there are more, so the modal can arrow through ALL of the post's images.
|
|
if ((props.post.thumbnails_more || 0) === 0) {
|
|
return images.value.map((t) => t.image_id)
|
|
}
|
|
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)
|
|
}
|
|
|
|
async function openModal (imageId) {
|
|
modal.open(imageId, { playlistIds: await fullImageIds() })
|
|
}
|
|
|
|
async function openModalAtMore () {
|
|
const ids = await fullImageIds()
|
|
const first = ids[visibleCount.value] ?? ids[0]
|
|
if (first != null) modal.open(first, { playlistIds: ids })
|
|
}
|
|
|
|
// --- description "Show more" (text-only, in place, only when truncated) ----
|
|
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)
|
|
// Backend-sanitized HTML body (detail-only). Drives the faithful render once
|
|
// expanded; falls back to plain text when detail isn't loaded.
|
|
const descHtml = computed(() => detail.value?.description_html || null)
|
|
// Off-platform file-host links (detail-only), surfaced once expanded.
|
|
const externalLinks = computed(() => detail.value?.external_links || [])
|
|
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,
|
|
)
|
|
|
|
// --- translation-forward (#143): the English is shown by default when a
|
|
// translation exists; a per-card toggle reveals the original. Translations are
|
|
// plain text, so showTranslated also decides HTML-vs-plain body rendering.
|
|
const showOriginal = ref(false)
|
|
const hasTranslation = computed(
|
|
() => !!(props.post.post_title_translated || props.post.description_translated),
|
|
)
|
|
const showTranslated = computed(() => hasTranslation.value && !showOriginal.value)
|
|
const sourceLangLabel = computed(() =>
|
|
props.post.translated_source_lang ? ` (${props.post.translated_source_lang})` : '',
|
|
)
|
|
const displayTitle = computed(() =>
|
|
showTranslated.value && props.post.post_title_translated
|
|
? toPlainText(props.post.post_title_translated)
|
|
: plainTitle.value,
|
|
)
|
|
const descTextDisplay = computed(() => {
|
|
if (!showTranslated.value) return descText.value
|
|
return descExpanded.value
|
|
? (detail.value?.description_translated_full || props.post.description_translated)
|
|
: props.post.description_translated
|
|
})
|
|
const showHtmlBody = computed(
|
|
() => !showTranslated.value && descExpanded.value && !!descHtml.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) {
|
|
// Fetch detail on first expand to get the sanitized HTML body (and the full
|
|
// plain text) — render the formatted body, not just the truncated preview.
|
|
if (!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) {
|
|
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-card {
|
|
padding: 1rem;
|
|
margin-bottom: 1rem;
|
|
container-type: inline-size;
|
|
}
|
|
|
|
.fc-post-card__head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
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));
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
}
|
|
.fc-post-card__artist:hover { color: rgb(var(--v-theme-accent)); }
|
|
.fc-post-card__date,
|
|
.fc-post-card__meta { white-space: nowrap; }
|
|
|
|
.fc-post-card__body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
@container (min-width: 800px) {
|
|
.fc-post-card__body {
|
|
flex-direction: row;
|
|
gap: 24px;
|
|
}
|
|
.fc-post-card__media { flex: 0 0 50%; }
|
|
.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 {
|
|
width: 100%;
|
|
aspect-ratio: 16 / 10;
|
|
overflow: hidden;
|
|
border-radius: 6px;
|
|
}
|
|
.fc-post-card__hero img {
|
|
width: 100%; height: 100%;
|
|
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); }
|
|
|
|
/* Equal columns spanning the hero's full width — cells stretch horizontally
|
|
(1fr) at a fixed height, so the strip always reaches the edge of the hero
|
|
regardless of how many thumbnails the post has. */
|
|
.fc-post-card__rail {
|
|
display: grid;
|
|
grid-template-columns: repeat(var(--fc-rail-cols, 3), 1fr);
|
|
gap: 6px; margin-top: 6px;
|
|
}
|
|
.fc-post-card__rail-cell {
|
|
width: 100%; height: 84px;
|
|
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: 100%; height: 84px;
|
|
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));
|
|
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 {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-size: 18px; font-weight: 700;
|
|
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__title--missing {
|
|
font-style: italic;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
@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;
|
|
white-space: pre-wrap;
|
|
}
|
|
/* Clamp ONLY while collapsed; expanding drops the clamp to show it all. */
|
|
.fc-post-card__desc--clamped {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 3;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
@container (min-width: 800px) {
|
|
.fc-post-card__desc--clamped { -webkit-line-clamp: 5; }
|
|
}
|
|
/* Faithful HTML body (expanded). Drop the plain-text pre-wrap and style the
|
|
semantic tags the backend sanitizer allows. :deep() pierces scoped CSS to
|
|
reach the v-html subtree. */
|
|
.fc-post-card__desc--html { white-space: normal; }
|
|
.fc-post-card__desc--html :deep(p) { margin: 0 0 0.6em; }
|
|
.fc-post-card__desc--html :deep(p:last-child) { margin-bottom: 0; }
|
|
.fc-post-card__desc--html :deep(h1),
|
|
.fc-post-card__desc--html :deep(h2),
|
|
.fc-post-card__desc--html :deep(h3),
|
|
.fc-post-card__desc--html :deep(h4),
|
|
.fc-post-card__desc--html :deep(h5),
|
|
.fc-post-card__desc--html :deep(h6) {
|
|
margin: 0.8em 0 0.4em;
|
|
line-height: 1.25;
|
|
font-weight: 700;
|
|
}
|
|
.fc-post-card__desc--html :deep(h1) { font-size: 1.3rem; }
|
|
.fc-post-card__desc--html :deep(h2) { font-size: 1.2rem; }
|
|
.fc-post-card__desc--html :deep(h3) { font-size: 1.1rem; }
|
|
.fc-post-card__desc--html :deep(h4),
|
|
.fc-post-card__desc--html :deep(h5),
|
|
.fc-post-card__desc--html :deep(h6) { font-size: 1rem; }
|
|
.fc-post-card__desc--html :deep(a) {
|
|
color: rgb(var(--v-theme-accent));
|
|
text-decoration: underline;
|
|
word-break: break-word;
|
|
}
|
|
.fc-post-card__desc--html :deep(img) {
|
|
max-width: 100%;
|
|
height: auto;
|
|
border-radius: 6px;
|
|
margin: 0.4em 0;
|
|
}
|
|
.fc-post-card__desc--html :deep(ul),
|
|
.fc-post-card__desc--html :deep(ol) { margin: 0 0 0.6em; padding-left: 1.4em; }
|
|
.fc-post-card__desc--html :deep(li) { margin: 0.15em 0; }
|
|
.fc-post-card__desc--html :deep(blockquote) {
|
|
margin: 0.6em 0;
|
|
padding-left: 0.8em;
|
|
border-left: 3px solid rgb(var(--v-theme-on-surface-variant));
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
.fc-post-card__desc--html :deep(pre) {
|
|
background: rgba(var(--v-theme-on-surface), 0.06);
|
|
padding: 0.6em; border-radius: 6px; overflow-x: auto;
|
|
}
|
|
.fc-post-card__desc--html :deep(code) { font-family: monospace; font-size: 0.85em; }
|
|
.fc-post-card__desc--html :deep(hr) {
|
|
border: 0; border-top: 1px solid rgb(var(--v-theme-on-surface-variant));
|
|
margin: 0.8em 0; opacity: 0.4;
|
|
}
|
|
.fc-post-card__desc--html :deep(figure) { margin: 0.4em 0; }
|
|
.fc-post-card__desc--missing {
|
|
font-style: italic;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
|
|
.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; }
|
|
/* Translation toggle (#143) — quiet secondary affordance under the title. */
|
|
.fc-post-card__lang {
|
|
display: block; margin: 2px 0 6px; padding: 0;
|
|
background: none; border: 0; cursor: pointer;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
font-size: 0.75rem; font-weight: 600;
|
|
}
|
|
.fc-post-card__lang:hover {
|
|
text-decoration: underline; color: rgb(var(--v-theme-accent));
|
|
}
|
|
.fc-post-card__lang:focus-visible {
|
|
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
|
|
}
|
|
|
|
.fc-post-card__atts {
|
|
display: flex; flex-wrap: wrap; gap: 8px;
|
|
margin-top: 12px;
|
|
}
|
|
.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-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__links {
|
|
display: flex; flex-direction: column; gap: 6px;
|
|
margin-top: 12px;
|
|
}
|
|
.fc-post-card__links-label {
|
|
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
.fc-post-card__link {
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
padding: 6px 12px;
|
|
border: 1px solid rgb(var(--v-theme-on-surface-variant));
|
|
border-radius: 8px;
|
|
color: rgb(var(--v-theme-on-surface));
|
|
text-decoration: none;
|
|
font-size: 0.85rem;
|
|
}
|
|
.fc-post-card__link:hover {
|
|
color: rgb(var(--v-theme-accent));
|
|
border-color: rgb(var(--v-theme-accent));
|
|
}
|
|
.fc-post-card__link-host {
|
|
flex: 0 0 auto;
|
|
font-weight: 700; text-transform: uppercase; font-size: 0.7rem;
|
|
letter-spacing: 0.04em;
|
|
padding: 2px 6px; border-radius: 4px;
|
|
background: rgba(var(--v-theme-accent), 0.16);
|
|
color: rgb(var(--v-theme-accent));
|
|
}
|
|
.fc-post-card__link-text {
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.fc-post-card__link-status {
|
|
flex: 0 0 auto; margin-left: auto;
|
|
font-size: 0.7rem; text-transform: uppercase;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
.fc-post-card__link-status[data-status="downloaded"] {
|
|
color: rgb(var(--v-theme-accent));
|
|
}
|
|
</style>
|