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
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
743 lines
28 KiB
Vue
743 lines
28 KiB
Vue
<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>
|