Files
FabledCurator/frontend/src/components/posts/PostCard.vue
T
bvandeusenandClaude Opus 5 98c3b74260
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 6s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m1s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m10s
feat: the Latest feed uses a wide window — filmstrip cards, a day gutter and a filter rail (407 A, D, E)
On the operator's 3432px window the feed was a 900px column, 26% of the width. They picked three options from a to-scale layout study.

A — filmstrip card (PostCard.vue):
- A card measures itself with a ResizeObserver. At 1100px or wider its hero gets a fixed height, clamp(260px, 34vh, 460px), and the extra images move into a 2-column grid of squares beside it. The grid cells are sized from the hero height, so the grid ends flush with the hero.
- The rail cap is 4 cells in this layout (2×2, the last becoming "+N") and 5 in the narrow layout, which is unchanged.
- The description clamp drops to 4 lines, because long reads happen in the expanded view.
- The hero has a height, not a width, so a wide card can't grow into a full-screen post. That was the operator's constraint.

D — day gutter (PostsView.vue):
- The normal feed groups consecutive posts by local day (Today, Yesterday, a weekday, or a date), with post and artist counts for what has loaded.
- Runs rather than date buckets, because the sort key includes resurfaced_at, which the payload doesn't carry. A resurfaced grouping gets its own heading where it actually appears, instead of being pulled out of order.

E — filter rail (PostsView.vue):
- At 1600px and wider, the filters and status ribbon stack in a sticky 280px left rail, and each day's heading sits in a sticky 150px gutter beside its posts.
- Below 1600px the layout is exactly the old one, including the 900px column.

The in-context (post_id) view gets the wide column but no rail or day grouping, so anchor scrolling is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
2026-09-13 19:48:46 -04:00

743 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<v-card
ref="cardEl" class="fc-post-card" variant="outlined"
:class="{ 'fc-post-card--wide': wide }"
>
<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>
<!-- The honesty marker (#388 E2). Discord doesn't publish posts, so FC
groups a creator's drop and writes the post itself. This chip is the
only thing standing between "FC assembled this" and the card reading
as something the artist authored it must never be conditional on
anything but the flag, and it says what it was built FROM so the
claim is checkable rather than just disclosed. -->
<v-chip
v-if="synthesized" size="x-small" variant="outlined"
class="fc-post-card__synthetic" :title="synthesisTitle"
>
<v-icon icon="mdi-auto-fix" size="x-small" start />
grouped by FabledCurator
</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>
<!-- Only on a grouping that has actually grown. An ordinary post can
never show this, and a grouping that has not grown says nothing
an "updated" label that is always present teaches you to ignore it. -->
<span v-if="grewAt" class="fc-post-card__meta fc-post-card__grew">
· updated {{ grewRelative }}
</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, '--fc-grid-cols': Math.min(2, 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>
<!-- A synthetic post has no title on purpose (inventing one is the one
place this feature could put words in a creator's mouth), so name
it for what it is rather than leaking `fc-drop:<message id>`. -->
<h3
v-else-if="synthesized"
class="fc-post-card__title fc-post-card__title--missing"
>{{ synthesisTitle }}</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>
<!-- Per-post translation override (#155): force a skipped translation on,
or keep the original for a confidently mis-flagged title. Only where
there's text to translate. -->
<PostTranslationControl
v-if="post.post_title || post.description_plain"
:post="post"
/>
<!-- 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>
<!-- #388 E5: the accepted announcement link, both directions. Only
ACCEPTED ones reach the payload, so anything rendered here is
something the operator agreed to — never a proposal. -->
<!-- A location with no name/path is "the current route, with these
query params" — so this works from Latest or Browse alike without
the card reaching for useRoute(), which it has no other reason to
know about. -->
<div v-if="associations.length" class="fc-post-card__assoc">
<RouterLink
v-for="a in associations" :key="a.id"
:to="{ query: { post_id: a.post_id } }"
class="fc-post-card__assoc-link"
>
<v-icon icon="mdi-link-variant" size="x-small" />
{{ a.role === 'announces'
? 'The full set is in Discord' : 'Announced on Patreon' }}
</RouterLink>
</div>
<!-- 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'
import PostTranslationControl from './PostTranslationControl.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))
// #388 E2. Non-null `synthesized_by` means FC authored this row by grouping a
// creator's drop; `synthesis` carries what it was built from. Read defensively
// — a post fetched before the field existed, or any surface that composes a
// post dict by hand, must degrade to "not synthetic" rather than throw.
const synthesized = computed(() => Boolean(props.post.synthesized_by))
const messageCount = computed(() => props.post.synthesis?.message_count ?? 0)
const synthesisTitle = computed(() => {
const n = messageCount.value
if (!n) return 'Grouped from Discord'
return `Grouped from ${n} Discord message${n === 1 ? '' : 's'}`
})
const hero = computed(() => images.value[0])
// Filmstrip layout (milestone #407, option A). On a very wide window a card
// that just grew would give one post the whole screen, so a WIDE card instead
// pins the hero to a fixed height and moves the extra images into a 2-column
// grid BESIDE it. Measured on the card rather than the viewport because the
// same card renders in the Latest feed, Browse, and the in-context view, each
// at a different width.
const WIDE_CARD_PX = 1100
const cardEl = ref(null)
const wide = ref(false)
// The narrow layout's strip spans the hero's full width (CSS grid, equal
// columns); the wide layout's grid is 2×2. Show up to that many cells; when
// there are more images than fit, the last cell becomes a "+N" overflow tile so
// the count stays accurate.
const RAIL_MAX_NARROW = 5
const RAIL_MAX_WIDE = 4
const serverMore = computed(() => props.post.thumbnails_more || 0)
const afterHero = computed(() => images.value.slice(1))
const railMax = computed(() => (wide.value ? RAIL_MAX_WIDE : RAIL_MAX_NARROW))
const hasOverflow = computed(
() => serverMore.value > 0 || afterHero.value.length > railMax.value,
)
const rail = computed(() =>
hasOverflow.value
? afterHero.value.slice(0, railMax.value - 1)
: afterHero.value.slice(0, railMax.value),
)
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)
// #388 E3. A grouping stays OPEN, so its own date and its latest activity are
// different facts. The card keeps showing when the drop STARTED — that is the
// post's identity — and reports growth separately, because "this post is from
// Tuesday but gained images this morning" is the whole signal that chat
// content is trickling in.
const grewAt = computed(() => (synthesized.value ? props.post.last_grew_at : null))
// #388 E5. Accepted links only — a pending proposal lives in the review queue,
// never beside the artwork. Defaults to [] so a post dict from before the
// feature (or composed by hand) renders without a link rather than throwing.
const associations = computed(() => props.post.associations || [])
const grewRelative = computed(() => (grewAt.value ? relativeFrom(grewAt.value) : ''))
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
function relativeFrom (iso) {
const then = new Date(iso).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(iso).toLocaleDateString()
}
const relativeDate = computed(() => relativeFrom(sortDateIso.value))
// --- 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
let cardRo = null
onMounted(() => {
nextTick(measureOverflow)
const root = cardEl.value?.$el
if (typeof ResizeObserver !== 'undefined' && root) {
cardRo = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect?.width ?? 0
wide.value = w >= WIDE_CARD_PX
})
cardRo.observe(root)
}
// 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 }
if (cardRo) { cardRo.disconnect(); cardRo = 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)); }
/* Quiet, not decorative: the marker has to be legible on every card without
turning a synthetic post into the loudest thing in the feed. */
.fc-post-card__synthetic {
color: rgb(var(--v-theme-on-surface-variant));
}
/* Growth is news, so it gets the accent the rest of the meta line doesn't —
but it is still the meta line, not a badge competing with the artwork. */
.fc-post-card__grew { color: rgb(var(--v-theme-accent)); }
.fc-post-card__assoc { margin-top: 8px; }
.fc-post-card__assoc-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.8125rem;
color: rgb(var(--v-theme-accent));
text-decoration: none;
}
.fc-post-card__assoc-link:hover { text-decoration: underline; }
.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));
}
/* --- Filmstrip layout (wide cards, #407 A) ---------------------------------
The hero has a HEIGHT, not a width: a wide card must not turn into a
full-screen post, so its height stays roughly a third of the viewport
whatever the window's width. The extra images sit beside it as square cells
whose size derives from that same height, so the grid always ends flush with
the hero's bottom edge. */
.fc-post-card--wide {
--fc-hero-h: clamp(260px, 34vh, 460px);
--fc-grid-gap: 8px;
--fc-cell: calc((var(--fc-hero-h) - var(--fc-grid-gap)) / 2);
}
.fc-post-card--wide .fc-post-card__body { flex-direction: row; gap: 24px; }
.fc-post-card--wide .fc-post-card__media {
flex: 0 0 auto;
display: flex;
gap: var(--fc-grid-gap);
}
.fc-post-card--wide .fc-post-card__hero {
width: auto;
height: var(--fc-hero-h);
}
.fc-post-card--wide .fc-post-card__rail {
margin-top: 0;
gap: var(--fc-grid-gap);
grid-template-columns: repeat(var(--fc-grid-cols, 2), var(--fc-cell));
grid-auto-rows: var(--fc-cell);
}
.fc-post-card--wide .fc-post-card__rail-cell,
.fc-post-card--wide .fc-post-card__rail-more { height: 100%; }
.fc-post-card--wide .fc-post-card__text { flex: 1 1 0; min-width: 0; }
/* Text is secondary here — long reads happen in the expanded view — so the
clamp keeps the text column no taller than the images beside it. */
.fc-post-card--wide .fc-post-card__desc--clamped { -webkit-line-clamp: 4; }
.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>