Card images: hierarchical setting, compact-card image, explicit card-mode override
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
// Banner editor tab: per-page banner HTML and banner design, previewed into
|
||||
// the real #page-banner region. Close and tab switching live in EditorShell.
|
||||
// the real #page-banner region, plus the page's card image (Node.image,
|
||||
// inherited by the subtree — the effective one previews, dimmed when
|
||||
// inherited). Close and tab switching live in EditorShell.
|
||||
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
import { Compartment, EditorState } from '@codemirror/state'
|
||||
@@ -23,6 +25,7 @@ const path = ref('')
|
||||
const banner = ref('')
|
||||
const saveError = ref('')
|
||||
const fileInput = ref(null)
|
||||
const imageInput = ref(null)
|
||||
const bannerEl = ref(null)
|
||||
|
||||
let ws = null
|
||||
@@ -63,6 +66,91 @@ const bannerDesignInherited = ref('')
|
||||
// whose re-render must not race the save it triggers).
|
||||
let refreshOnSave = null
|
||||
|
||||
// --- Card image (Node.image, '' = inherit, like the banner design) ------
|
||||
// The node's own setting, the effective image after inheritance ("" =
|
||||
// none) and which node supplied an inherited one ("" = the front page,
|
||||
// "" also when own/none — mirrors bannerFrom).
|
||||
const image = ref('')
|
||||
const imageResolved = ref('')
|
||||
const imageSource = ref('')
|
||||
// The image the server would mine from the article itself — the card
|
||||
// previews fall back to it when no node image resolves (mirrors og:image).
|
||||
const imageMined = ref('')
|
||||
// Whether the page has children (from the doc message): an own share
|
||||
// image is inherited by the whole section.
|
||||
const hasChildren = ref(false)
|
||||
// The block label states which image is currently in use.
|
||||
const imageLabel = computed(() => {
|
||||
if (image.value) {
|
||||
return hasChildren.value
|
||||
? `card image: set for this article — used in /${path.value}/*`
|
||||
: 'card image: set for this article'
|
||||
}
|
||||
if (imageResolved.value) {
|
||||
const where = imageSource.value === '' ? 'the front page' : `/${imageSource.value}`
|
||||
return `card image: inherited from ${where}`
|
||||
}
|
||||
if (imageMined.value) return 'card image: from the article'
|
||||
return 'card image: none'
|
||||
})
|
||||
// The page title and description (from the doc message) feed the mock card
|
||||
// previews; empty shows placeholder bars / text instead.
|
||||
const pageTitle = ref('')
|
||||
const pageDesc = ref('')
|
||||
// The image the Twitter cards preview with: the resolved node card image,
|
||||
// else the mined article image (what og:image would use). image_resolved is
|
||||
// a bare store hash; image_mined is already a src path.
|
||||
const cardImage = computed(() =>
|
||||
imageResolved.value ? `/_f/${imageResolved.value}` : imageMined.value,
|
||||
)
|
||||
// Card-mode override (Node.large, per-article, NOT inherited):
|
||||
// null = automatic, false = small, true = large.
|
||||
const large = ref(null)
|
||||
// Approximation of the server's automatic pick for the "automatic"
|
||||
// marker: the real check probes image dimensions (>= 600px wide,
|
||||
// landscape-ish AR) server-side, unavailable here — presence of an
|
||||
// effective image stands in for "large".
|
||||
const autoLarge = computed(() => !!cardImage.value)
|
||||
const effectiveLarge = computed(() => large.value ?? autoLarge.value)
|
||||
|
||||
function toggleCard(forced) {
|
||||
// Clicking the already-selected card deselects back to automatic.
|
||||
const msg = {
|
||||
type: 'save',
|
||||
path: normPath(path.value),
|
||||
large: large.value === forced ? null : forced,
|
||||
}
|
||||
large.value = msg.large
|
||||
pendingSave = msg
|
||||
send(msg)
|
||||
// twitter:card is part of the page head: re-render on ack.
|
||||
refreshOnSave = rerender
|
||||
}
|
||||
function saveImage(hash) {
|
||||
const msg = { type: 'save', path: normPath(path.value), image: hash }
|
||||
pendingSave = msg
|
||||
send(msg)
|
||||
// The card image feeds the card previews, card covers and social meta:
|
||||
// on ack re-open the doc (fresh image/image_resolved/image_source — the
|
||||
// banner itself saves in real time, so nothing is lost) and re-render.
|
||||
refreshOnSave = () => {
|
||||
openPath(normPath(path.value))
|
||||
rerender()
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadCardImage(ev) {
|
||||
// Card images go to the shared content store, like banner media.
|
||||
const file = ev.target.files[0]
|
||||
ev.target.value = '' // allow re-picking the same file
|
||||
if (!file || !file.type.startsWith('image/')) return
|
||||
const name = file.name.replace(/[^\w.-]/g, '-')
|
||||
const res = await apiFetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
|
||||
if (!res.ok) return
|
||||
const { path: stored } = await res.json() // "/_f/<hash>[.ext]"
|
||||
saveImage(stored.split('/').pop().split('.')[0])
|
||||
}
|
||||
|
||||
// The inherit option names the design actually in effect and its source.
|
||||
const inheritLabel = computed(() => {
|
||||
if (bannerDesignFrom.value === null) {
|
||||
@@ -234,6 +322,14 @@ function onMessage(ev) {
|
||||
bannerDesignFrom.value = msg.banner_design_from ?? null
|
||||
bannerDesignInherited.value = msg.banner_design_inherited ?? ''
|
||||
bannerFrom.value = msg.banner_from ?? null
|
||||
image.value = msg.image ?? ''
|
||||
imageResolved.value = msg.image_resolved ?? ''
|
||||
imageMined.value = msg.image_mined ?? ''
|
||||
imageSource.value = msg.image_source ?? ''
|
||||
hasChildren.value = msg.has_children ?? false
|
||||
large.value = msg.large ?? null
|
||||
pageTitle.value = msg.title ?? ''
|
||||
pageDesc.value = msg.description ?? ''
|
||||
if (banner.value.trim()) previewBanner()
|
||||
} else if (msg.type === 'saved') {
|
||||
saveError.value = ''
|
||||
@@ -339,7 +435,66 @@ onUnmounted(() => {
|
||||
<div v-if="saveError">{{ saveError }}</div>
|
||||
<ConnNote :text="connNote" />
|
||||
|
||||
<section class="block" @paste="onBannerPaste">
|
||||
<section class="block card-image">
|
||||
<div class="block-head">
|
||||
<span class="block-label">{{ imageLabel }}</span>
|
||||
<button
|
||||
v-if="image"
|
||||
type="button"
|
||||
class="icon-btn del"
|
||||
title="clear the card image (back to inherit)"
|
||||
@click="saveImage('')"
|
||||
>❌</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="upload card image (og:image / card covers) — the subtree inherits it"
|
||||
@click="imageInput.click()"
|
||||
>🖼︎</button>
|
||||
<input
|
||||
ref="imageInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
@change="uploadCardImage"
|
||||
/>
|
||||
</div>
|
||||
<!-- The site's own cards double as the card-mode selector: rendered
|
||||
with the real .card styles from pagerite.css (theme variables
|
||||
and all — they ARE the site's look). Clicking one forces that
|
||||
mode (Node.large), clicking the selected one returns to
|
||||
automatic. The description only exists in the small format,
|
||||
like the backend's _card. -->
|
||||
<div class="site-previews">
|
||||
<button
|
||||
type="button"
|
||||
class="card compact preview"
|
||||
:class="{ selected: large === false, auto: large === null && !effectiveLarge }"
|
||||
title="small card — click to force it, click again for automatic"
|
||||
@click="toggleCard(false)"
|
||||
>
|
||||
<span class="top">
|
||||
<img v-if="cardImage" class="cover" :src="cardImage" alt="" />
|
||||
<span class="title">{{ pageTitle || 'page title' }}</span>
|
||||
</span>
|
||||
<span class="bottom">
|
||||
<span v-if="pageDesc" class="desc">{{ pageDesc }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="card preview"
|
||||
:class="{ selected: large === true, auto: large === null && effectiveLarge }"
|
||||
title="large card — click to force it, click again for automatic"
|
||||
@click="toggleCard(true)"
|
||||
>
|
||||
<span class="cover" :style="cardImage ? `background-image: url('${cardImage}')` : null" />
|
||||
<span class="title">{{ pageTitle || 'page title' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block banner-block" @paste="onBannerPaste">
|
||||
<div class="block-head">
|
||||
<select
|
||||
v-model="bannerDesign"
|
||||
@@ -356,7 +511,7 @@ onUnmounted(() => {
|
||||
class="icon-btn"
|
||||
title="upload banner image/video (replaces existing media) — pasting works too"
|
||||
@click="fileInput.click()"
|
||||
>🖼️</button>
|
||||
>🖼︎</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
@@ -385,10 +540,61 @@ onUnmounted(() => {
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.banner-block {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The card-image section: a real preview of the effective image (the
|
||||
node's own or the inherited one, dimmed then), with upload/clear in the
|
||||
head row like the banner media button. */
|
||||
.card-image {
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.block-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* The site's own card previews: real .card markup/styles from pagerite.css,
|
||||
scaled down via font-size (the card internals are all em, so the layout
|
||||
proportions match the real cards exactly). They double as the card-mode
|
||||
selector: thin outlines only (no border changes, so selecting never
|
||||
shifts the layout) — solid accent for a forced mode, dashed muted for
|
||||
the mode "automatic" currently resolves to (approximated from image
|
||||
presence). */
|
||||
.site-previews {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.site-previews .card.preview {
|
||||
font: inherit;
|
||||
font-size: 0.67rem;
|
||||
width: 24em;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.site-previews .card.preview.selected {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.site-previews .card.preview.auto:not(.selected) {
|
||||
outline: 1px dashed var(--muted);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.block-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -401,11 +607,16 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.block-head .icon-btn {
|
||||
margin-left: auto;
|
||||
padding: 0 0.2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* The first icon button pushes itself (and any siblings after it, like
|
||||
the card-image clear button) to the end of the row. */
|
||||
.block-head .icon-btn:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* The banner design selector stays compact; the upload button is pushed
|
||||
right by its auto margin. */
|
||||
.design-select {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
|
||||
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as
|
||||
// {slug, path, title, translated, order, published, has_content, language,
|
||||
// primary, children}. The row's flag (LangSelect) sets the node's primary
|
||||
// language (language; '' = inherit — dimmed, showing the resolved flag);
|
||||
// the setting covers the whole subtree.
|
||||
// primary, children}. The row's flag
|
||||
// (LangSelect) sets the node's primary language (language; '' = inherit —
|
||||
// dimmed, showing the resolved flag); the setting covers the whole subtree.
|
||||
// With a `lang` prop (StructureEditor's language strip) the titles shown
|
||||
// are that language's; `translated` marks rows with an actual translation
|
||||
// (untranslated rows show the original title, dimmed).
|
||||
|
||||
@@ -287,7 +287,7 @@ body {
|
||||
|
||||
#nav span {
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
#nav .current {
|
||||
@@ -485,16 +485,25 @@ main {
|
||||
404) lists its published children after the markdown content — one
|
||||
card per child (a child without a page of its own is represented by
|
||||
its first leaf page; see _cards in views.py). The row bleeds to full
|
||||
page width (div.wide): the cards first grow to fill it, then shrink
|
||||
rather than wrap. Every card has the same fixed 16/10 shape, covered
|
||||
entirely by the
|
||||
page's share image (og:image heuristics, as a background — a gradient
|
||||
placeholder when it has none) with the title overlaid on a translucent
|
||||
band at the bottom. The card is one <a> holding only phrasing-level
|
||||
spans; the spans lay out as blocks. */
|
||||
page width (div.wide); every card has the same fixed 16/10 shape,
|
||||
scaling only with the available width. Cards come in two modes
|
||||
following the child's twitter:card selection (_card_large — the
|
||||
per-article override, else the card image's dimensions): large cards
|
||||
are covered entirely by the page's card image (og:image heuristics, as
|
||||
a background — a gradient placeholder when it has none) with the title
|
||||
overlaid on a translucent band at the bottom; small cards (.card.compact)
|
||||
split at the golden ratio: a square cover in the top part with the
|
||||
title beside it, the article description below. The card is one <a>
|
||||
holding only phrasing-level spans; the
|
||||
spans lay out as blocks. */
|
||||
/* Equal-width columns (grid, not flex: no differential shrink, no
|
||||
cross-axis stretch — every card keeps the fixed 16/10 shape, scaling
|
||||
only with the available width). Cards stop growing at their cap; the
|
||||
row centers in the bleed then. */
|
||||
.cards {
|
||||
display: flex;
|
||||
/* Cards stop growing at their cap; center the row in the bleed then. */
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(0, 24em);
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
margin-top: 2.5rem;
|
||||
@@ -503,39 +512,36 @@ main {
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
|
||||
/* Phones: the cards stack vertically instead of shrinking to slivers.
|
||||
Text-only cards (gradient cover + description) then fit their content —
|
||||
the fixed 16/10 shape only makes sense for image covers. */
|
||||
/* Phones: the cards stack vertically instead of shrinking to slivers. */
|
||||
@media (max-width: 48rem) {
|
||||
.cards {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card:has(.desc) {
|
||||
aspect-ratio: auto;
|
||||
grid-auto-flow: row;
|
||||
grid-auto-columns: unset;
|
||||
grid-template-columns: minmax(0, 24em);
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
/* Grow to fill the row (up to the cap — full-width rows would make huge
|
||||
cards), shrink (not wrap) when there are too many. */
|
||||
flex: 1 1 0;
|
||||
max-width: 24rem;
|
||||
/* Fill the (equal-width) grid column; the height comes only from the
|
||||
fixed aspect ratio (align-self: start — the row must not stretch it).
|
||||
Overflowing content is clipped. */
|
||||
width: 100%;
|
||||
align-self: start;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
aspect-ratio: 16 / 10;
|
||||
/* Allow shrinking below the text's min-content width: without this the
|
||||
text cards hold their row wider than the image-only cards. */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--surface);
|
||||
border-radius: 0.5em;
|
||||
/* Lifted from the page background (a touch of the text color) so cards
|
||||
read as distinct surfaces. */
|
||||
background: color-mix(var(--surface) 88%, var(--text));
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 0 0.1rem black;
|
||||
box-shadow: 0 0 0.1em black;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@@ -555,16 +561,22 @@ main {
|
||||
|
||||
.card .title {
|
||||
position: relative;
|
||||
display: block;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
margin-top: auto;
|
||||
padding: 0.75rem 1.25rem 0.9rem;
|
||||
background: color-mix(var(--bg) 40%, transparent);
|
||||
padding: 0.75em 1.25em 0.9em;
|
||||
background: color-mix(var(--bg) 65%, transparent);
|
||||
/* Pinned: the article a:hover accent must not leak through the card. */
|
||||
color: var(--text);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.25em;
|
||||
font-weight: 900;
|
||||
line-height: 1.25;
|
||||
/* Hyphenation needs the card's lang (the target article's language). */
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
/* Image-less cards carry the description under the title, extending the
|
||||
@@ -575,19 +587,112 @@ main {
|
||||
|
||||
.card .desc {
|
||||
position: relative;
|
||||
display: block;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
overflow: hidden;
|
||||
/* Four lines exactly: the text itself is truncated server-side (_cards),
|
||||
this is just the safety net. max-height spans the lines plus the
|
||||
vertical padding (border-box), so nothing bleeds past the clip. */
|
||||
/* Four lines exactly, with an ellipsis on overflow: the text itself is
|
||||
truncated server-side (_cards), this is just the safety net. */
|
||||
line-height: 1.4;
|
||||
max-height: calc(4 * 1.4em + 1.3rem);
|
||||
padding: 0.4rem 1.25rem 0.9rem;
|
||||
background: color-mix(var(--bg) 30%, transparent);
|
||||
padding: 0.4em 1.25em 0.9em;
|
||||
background: color-mix(var(--bg) 55%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.95em;
|
||||
text-align: start;
|
||||
hyphens: none;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
/* Small cards (_card_large false; the twitter "summary" style): externally
|
||||
the same 16/10 shape as the large card, split horizontally in two
|
||||
sub-grids at the golden ratio — the top part (the image, its full
|
||||
height, with the title beside it at the bottom) takes φ, the bottom
|
||||
part (the description — which only the small format carries — above
|
||||
spare space) takes 1, so the image's bottom edge sits at ~62% of the
|
||||
card instead of centering wherever the differing title/description
|
||||
sizes land it. The title and description carry the translucent band
|
||||
(the same band color as the large cards' title) as their own
|
||||
backgrounds. Imageless cards are just the gradient with the title
|
||||
spanning the full width. */
|
||||
.card.compact {
|
||||
display: grid;
|
||||
/* minmax(0, …): the parts never grow past the card — overlong text is
|
||||
line-clamped (title/description) instead of pushing the layout. */
|
||||
grid-template-rows: minmax(0, 1.618fr) minmax(0, 1fr);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(var(--surface) 88%, var(--text)),
|
||||
var(--bg)
|
||||
);
|
||||
}
|
||||
|
||||
/* Top part: spare space above the title row. The image column is as wide
|
||||
as the top part is tall — the card is 16/10 and the top is φ/(φ+1) of
|
||||
its height, i.e. ~38.6% of its width — so a square image at that width
|
||||
fills the whole top part. */
|
||||
.card.compact .top {
|
||||
display: grid;
|
||||
grid-template-columns: 38.6% 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The image fills its whole grid area (column 1, both rows); cover crops
|
||||
rather than letterboxes. */
|
||||
.card.compact img.cover {
|
||||
position: static;
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 3;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
object-fit: cover;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.card.compact .title {
|
||||
/* Full width: the translucent band runs behind the image to the left
|
||||
edge (the image paints over it, z-index 2), the text indented past
|
||||
the image column. */
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 0.4em 0.6em 0 calc(38.6% + 0.6em);
|
||||
background: color-mix(var(--bg) 65%, transparent);
|
||||
}
|
||||
|
||||
/* No image: no indentation, the text starts at the left edge. */
|
||||
.card.compact .top:not(:has(img.cover)) .title {
|
||||
padding-left: 0.6em;
|
||||
}
|
||||
|
||||
/* No description: the title supplies the band's bottom padding. */
|
||||
.card.compact:not(:has(.desc)) .title {
|
||||
padding-bottom: 0.4em;
|
||||
}
|
||||
|
||||
/* Bottom part: the description above spare space. */
|
||||
.card.compact .bottom {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.card.compact .desc {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 0.4em 0.6em;
|
||||
background: color-mix(var(--bg) 65%, transparent);
|
||||
/* Three lines fit the bottom part (⅜ of the card) even at the card cap;
|
||||
the line clamp adds the ellipsis past that. */
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
overflow: hidden;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
article h1,
|
||||
@@ -786,7 +891,7 @@ article {
|
||||
/* pagerite.js tucks the pen at the end of the article's first h1. */
|
||||
article h1 .edit-link {
|
||||
position: static;
|
||||
font-size: 1.1rem;
|
||||
font-size: 1.1em;
|
||||
vertical-align: 0.3em;
|
||||
margin-inline-start: 0.4rem;
|
||||
}
|
||||
@@ -1431,7 +1536,7 @@ article figure img {
|
||||
max-width: 65ch;
|
||||
padding: 0.8rem 1rem;
|
||||
color: var(--lightbox-text);
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.95em;
|
||||
text-align: center;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user