Files
ONE/frontend/src/components/RightRail.vue
T

377 lines
10 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.
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { publicApi } from '../api'
import { site } from '../site'
import { socialIcon } from '../socialIcons'
import { relativeDate, stripTags, thumbURL } from '../utils'
// vivid 是单栏布局,右栏整体不渲染(省掉 latest/tags 两次请求)。
const isVivid = computed(() => site.ui_id === 'vivid')
// 文章页目录(PostView 传入 headings;空数组时不渲染这张卡)。
// 高亮跟随滚动:视口上方最近的那个标题算「当前」。
const props = defineProps({
toc: { type: Array, default: () => [] }
})
const activeId = ref('')
let tocObserver = null
function watchToc() {
if (tocObserver) tocObserver.disconnect()
if (!props.toc.length) return
tocObserver = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) activeId.value = e.target.id
}
},
{ rootMargin: '0px 0px -65% 0px' }
)
nextTick(() => {
for (const t of props.toc) {
const el = document.getElementById(t.id)
if (el) tocObserver.observe(el)
}
})
}
watch(() => props.toc, watchToc, { deep: false })
onBeforeUnmount(() => tocObserver && tocObserver.disconnect())
// 项目源码仓库是固定入口(本站 git remote),不来自后台配置 —— 和版权一样
// 固定渲染在自己的那排;后台填的社交账号单独一排,URL 撞车的去重。
const PROJECT_REPO = 'https://git.gopher.ink/mirrors2/ONE'
// 社交 / 源码链接(后台「高级 → 社交链接」填写);url 必填,空的丢弃。
// 有图标的(按域名推导)渲染成单色图标,认不出的显示名称文字。
const social = computed(() => (site.social_links || []).filter((s) => s && s.url && s.url !== PROJECT_REPO))
const links = [
{ to: '/', label: '首页' },
{ to: '/archive', label: '归档' },
{ to: '/tags', label: '标签' },
{ to: '/projects', label: '作品' },
{ to: '/about', label: '关于' }
]
const latest = ref([])
const tags = ref([])
// 短文没有标题,侧栏拿正文开头当标识(flomo/Twitter 的做法),
// 纯图片短文显示占位;有配图时右侧带一张小缩略图。
const EXCERPT_LEN = 22
function excerpt(p) {
if (p.kind !== 'short') return p.title || '无题'
// 先剥 <a>:贴链接的短文开头是 URL 本身,摘出来没有信息量;
// 剥完没字了(纯链接贴)就退到链接卡片的标题
const noLinks = (p.content_html || '').replace(/<a\b[^>]*>[\s\S]*?<\/a>/gi, ' ')
const text = stripTags(noLinks).replace(/\s+/g, ' ').trim()
if (text) return text.length > EXCERPT_LEN ? text.slice(0, EXCERPT_LEN) + '…' : text
if (p.link_card && p.link_card.title) {
const t = p.link_card.title
return t.length > EXCERPT_LEN ? t.slice(0, EXCERPT_LEN) + '…' : t
}
return p.images && p.images.length ? '📷 图片' : '短文'
}
function thumbOf(p) {
return p.kind === 'short' && p.images && p.images.length ? thumbURL(p.images[0], 160) : ''
}
onMounted(async () => {
if (isVivid.value) return
try {
const [posts, tagData] = await Promise.all([publicApi.latest(), publicApi.tags()])
latest.value = posts.items || []
tags.value = (tagData.tags || []).slice(0, 12)
} catch (e) {
latest.value = []
tags.value = []
}
})
</script>
<template>
<aside v-if="!isVivid" class="rail-right">
<section v-if="toc.length" class="card toc-card">
<p class="eyebrow">目录</p>
<ul class="toc-list">
<li v-for="t in toc" :key="t.id" :class="{ h3: t.level >= 3, on: activeId === t.id }">
<a :href="'#' + t.id" :class="{ on: activeId === t.id }">{{ t.text }}</a>
</li>
</ul>
</section>
<section class="card">
<p class="eyebrow">关于这里</p>
<p class="bio">{{ site.author_bio || site.site_desc }}</p>
<RouterLink to="/about" class="more">了解更多 →</RouterLink>
</section>
<section v-if="latest.length" class="card">
<p class="eyebrow">最近更新</p>
<ul class="list">
<li v-for="p in latest" :key="p.id">
<RouterLink :to="`/post/${p.slug}`" class="row" :class="{ withThumb: thumbOf(p) }">
<span class="row-main">
<span class="title">
{{ excerpt(p) }}
<span v-if="p.kind === 'short'" class="badge">短</span>
</span>
<span class="date">{{ relativeDate(p.published_at) }}</span>
</span>
<img
v-if="thumbOf(p)"
class="thumb"
:src="thumbOf(p)"
alt=""
loading="lazy"
decoding="async"
/>
</RouterLink>
</li>
</ul>
</section>
<section v-if="tags.length" class="card">
<p class="eyebrow">标签</p>
<div class="tags">
<RouterLink v-for="t in tags" :key="t.id" :to="`/tag/${t.slug}`" class="tag-chip">
{{ t.name }}<span class="count"> {{ t.count }}</span>
</RouterLink>
</div>
</section>
<!-- 订阅卡删掉了:RSS 入口在下方页脚小字里,一张卡片只为一个链接不值得 -->
<!-- 页脚信息(Twitter 式:右栏卡片下方的小字区)。通栏 ClassicFoot
已删:右栏在窄屏会堆到信息流下方,卡片之后正好接页脚小字,
所有宽度都不缺位。 -->
<footer class="rail-foot">
<div v-if="social.length" class="social-row">
<a
v-for="s in social"
:key="s.label + s.url"
class="social-icon"
:href="s.url"
:title="s.label"
:aria-label="s.label"
:target="s.url.startsWith('http') ? '_blank' : undefined"
:rel="s.url.startsWith('http') ? 'noopener' : undefined"
>
<svg v-if="socialIcon(s.url)" viewBox="0 0 24 24" aria-hidden="true"><path :d="socialIcon(s.url)" /></svg>
<span v-else>{{ s.label }}</span>
</a>
</div>
<nav class="foot-links" aria-label="页脚导航">
<template v-for="(l, i) in links" :key="l.to">
<span v-if="i" class="dot">·</span>
<RouterLink :to="l.to">{{ l.label }}</RouterLink>
</template>
<span class="dot">·</span>
<a href="/rss.xml">RSS</a>
</nav>
<!-- 项目信息:固定的源码图标 + 版权,永远在 -->
<div class="foot-meta">
<a class="social-icon" :href="PROJECT_REPO" title="项目源码" aria-label="项目源码" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="socialIcon(PROJECT_REPO)" /></svg>
</a>
<span class="copyright">
{{ site.footer_note }}<template v-if="site.icp"> · {{ site.icp }}</template>
</span>
</div>
</footer>
</aside>
</template>
<style scoped>
.rail-right {
position: sticky;
top: 0;
align-self: start;
padding: 28px 0 40px;
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
border: 1px solid var(--line);
border-radius: 4px;
background: var(--card);
padding: 16px 18px;
}
.bio {
margin: 8px 0 10px;
font-size: 13.5px;
line-height: 1.85;
color: var(--ink-soft);
}
.more {
font-size: 13px;
color: var(--accent);
}
/* 文章目录:当前小节高亮,层级缩进 */
.toc-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
font-size: 13px;
line-height: 1.6;
}
.toc-list li {margin: 2px 0;
border-left: 2px solid transparent;
padding-left: 8px;}
.toc-list li.h3 {padding-left: 20px;
font-size: 12.5px;}
.toc-list li.on {border-left-color: var(--accent);}
.toc-list a {color: var(--muted);
text-decoration: none;}
.toc-list li.on a, .toc-list a:hover {color: var(--accent);}
.list {
list-style: none;
margin: 8px 0 0;
padding: 0;
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-bottom: 1px dashed var(--line-soft);
}
.list li:last-child .row {
border-bottom: 0;
}
/* 文字列吃满剩余宽度,缩略图固定在右 */
.row-main {
flex: 1;
min-width: 0;
}
.title {
display: block;
font-size: 14px;
line-height: 1.7;
overflow-wrap: anywhere;
}
.thumb {
flex: 0 0 auto;
width: 34px;
height: 34px;
object-fit: cover;
border-radius: 6px;
background: var(--card);
border: 1px solid var(--line-soft);
}
.badge {
display: inline-block;
margin-left: 5px;
padding: 0 5px;
border: 1px solid var(--line);
border-radius: 2px;
font-size: 11px;
color: var(--muted);
vertical-align: 1px;
}
.date {
font-size: 12px;
color: var(--faint);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.count {
color: var(--faint);
font-size: 11px;
}
/* 页脚小字区:无卡片边框,Twitter 式侧栏页脚。classic 的页脚信息只在这里
出现(通栏 ClassicFoot 已删):右栏在窄屏会堆到信息流下方,卡片之后
正好接页脚小字,所有宽度都不缺位。 */
.rail-foot {
padding: 4px 6px 0;
font-size: 12px;
line-height: 1.9;
color: var(--faint);
}
.foot-links a {
color: var(--faint);
transition: color 0.15s;
}
.foot-links a:hover {
color: var(--accent);
}
.dot {
margin: 0 4px;
opacity: 0.6;
}
/* 社交按钮:页脚第一排居中 */
.social-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
margin-bottom: 10px;
}
.foot-meta {
display: flex;
align-items: center;
gap: 8px;
/* 项目信息行和上面的社交账号排拉开一截:它们是两类信息 */
margin-top: 16px;
}
.social-icon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 3px;
border-radius: 6px;
color: var(--faint);
font-size: 12px;
transition: color 0.15s;
}
.social-icon:hover {
color: var(--accent);
}
.social-icon svg {
width: 16px;
height: 16px;
fill: currentColor;
}
.copyright {
color: var(--faint);
}
/* 窄屏:底部留白加高,右下角浮动主题钮压不到页脚小字 */
@media (max-width: 780px) {
.rail-right {
padding-bottom: 96px;
}
}
</style>