feat: 前台标签接入 antd 组件、列表封面图、favicon 随站点 Logo 及侧栏收窄
- 前台列表/详情/标签页标签统一 antd Tag;标签页改 CheckableTagGroup 单选 (选中态由组件承接,再点已选标签回到全部);移除首页标签筛选条, 标签入口收敛至顶部「标签」页 - 公开列表新增 cover 字段(取正文第一张站内图,store.FirstImageRef), 首页条目左侧显示封面缩略图,无图条目保持纯文字 - favicon 随站点 Logo:webui.Meta.Icon 服务端注入 index.html, 前端 useSiteTitle 兜底(dev 模板占位符场景)并即时生效; 设置页「重置默认」清空 Logo 后回退内置 /favicon.svg - 后台侧栏改 antd Menu(inline 分组),宽度 256→208、窄侧栏档 224→184
This commit is contained in:
@@ -40,13 +40,14 @@ func (s *Server) handleSiteInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, ss)
|
writeJSON(w, http.StatusOK, ss)
|
||||||
}
|
}
|
||||||
|
|
||||||
// publicNoteItem 公开列表项(元信息,不含全文)。
|
// publicNoteItem 公开列表项(元信息 + 封面图,不含全文)。
|
||||||
type publicNoteItem struct {
|
type publicNoteItem struct {
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
|
Cover string `json:"cover,omitempty"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
PublishedAt int64 `json:"published_at"`
|
PublishedAt int64 `json:"published_at"`
|
||||||
@@ -71,6 +72,7 @@ func (s *Server) handlePublicNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
items = append(items, publicNoteItem{
|
items = append(items, publicNoteItem{
|
||||||
Slug: n.Slug, Title: n.Title, Summary: n.Summary,
|
Slug: n.Slug, Title: n.Title, Summary: n.Summary,
|
||||||
Tags: n.Tags, Pinned: n.Pinned,
|
Tags: n.Tags, Pinned: n.Pinned,
|
||||||
|
Cover: store.FirstImageRef(n.Content),
|
||||||
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, PublishedAt: n.PublishedAt,
|
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, PublishedAt: n.PublishedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ func (s *Server) ResolveMeta(r *http.Request) webui.Meta {
|
|||||||
OGType: "website",
|
OGType: "website",
|
||||||
SiteName: ss.SiteTitle,
|
SiteName: ss.SiteTitle,
|
||||||
OGURL: baseURL(r) + "/",
|
OGURL: baseURL(r) + "/",
|
||||||
|
Icon: favicon(ss.SiteLogo),
|
||||||
}
|
}
|
||||||
slug := metaSlug(r.URL.Path)
|
slug := metaSlug(r.URL.Path)
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
@@ -318,6 +319,14 @@ func (s *Server) ResolveMeta(r *http.Request) webui.Meta {
|
|||||||
return s.noteMeta(r, m, note)
|
return s.noteMeta(r, m, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// favicon favicon 地址:站点 Logo 优先,未设置回退内置图标。
|
||||||
|
func favicon(siteLogo string) string {
|
||||||
|
if siteLogo != "" {
|
||||||
|
return siteLogo
|
||||||
|
}
|
||||||
|
return "/favicon.svg"
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) noteMeta(r *http.Request, base webui.Meta, n *store.Note) webui.Meta {
|
func (s *Server) noteMeta(r *http.Request, base webui.Meta, n *store.Note) webui.Meta {
|
||||||
desc := n.Summary
|
desc := n.Summary
|
||||||
if desc == "" {
|
if desc == "" {
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ func (s *Store) ImageIsPublic(id int64) (bool, error) {
|
|||||||
// imageRefRe 扫描 content 中对 /api/images/{id} 的引用(§6.2 引用维护)。
|
// imageRefRe 扫描 content 中对 /api/images/{id} 的引用(§6.2 引用维护)。
|
||||||
var imageRefRe = regexp.MustCompile(`/api/images/(\d+)`)
|
var imageRefRe = regexp.MustCompile(`/api/images/(\d+)`)
|
||||||
|
|
||||||
|
// FirstImageRef 返回 content 中按出现顺序的第一张站内图片路径(无则空串)。
|
||||||
|
// 供公开列表推导封面图使用。
|
||||||
|
func FirstImageRef(content string) string {
|
||||||
|
m := imageRefRe.FindStringSubmatch(content)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m[0]
|
||||||
|
}
|
||||||
|
|
||||||
// RebuildImageRefs 在**同一事务**内重建笔记的图片引用:
|
// RebuildImageRefs 在**同一事务**内重建笔记的图片引用:
|
||||||
// 删除该笔记全部 refs → 正则扫描 content → 重建(§6.2)。
|
// 删除该笔记全部 refs → 正则扫描 content → 重建(§6.2)。
|
||||||
func (s *Store) RebuildImageRefs(noteID int64, content string) error {
|
func (s *Store) RebuildImageRefs(noteID int64, content string) error {
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ func (s *Store) ListPublicNotes(page, pageSize int, tag string) ([]Note, int, er
|
|||||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes n WHERE `+where, args...).Scan(&total); err != nil {
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes n WHERE `+where, args...).Scan(&total); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
q := `SELECT n.id, n.slug, n.title, n.summary, '' AS content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at, COALESCE(n.published_at, n.created_at) AS published_at
|
// 含全文:列表页需从正文推导封面图(FirstImageRef),个人规模条数有限可接受
|
||||||
|
q := `SELECT n.id, n.slug, n.title, n.summary, n.content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at, COALESCE(n.published_at, n.created_at) AS published_at
|
||||||
FROM notes n WHERE ` + where + `
|
FROM notes n WHERE ` + where + `
|
||||||
ORDER BY n.pinned DESC, n.updated_at DESC, n.id DESC LIMIT ? OFFSET ?`
|
ORDER BY n.pinned DESC, n.updated_at DESC, n.id DESC LIMIT ? OFFSET ?`
|
||||||
rows, err := s.db.Query(q, append(args, pageSize, (page-1)*pageSize)...)
|
rows, err := s.db.Query(q, append(args, pageSize, (page-1)*pageSize)...)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type Meta struct {
|
|||||||
OGURL string
|
OGURL string
|
||||||
OGImage string
|
OGImage string
|
||||||
SiteName string
|
SiteName string
|
||||||
|
Icon string // favicon:站点 Logo,未设置回退 /favicon.svg
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI 静态资源服务。
|
// UI 静态资源服务。
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,8 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<!-- favicon 随站点 Logo(服务端注入);dev 环境由前端 features/site 兜底替换 -->
|
||||||
|
<link rel="icon" href="{{.Icon}}" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<!--
|
<!--
|
||||||
以下占位符由服务端 internal/webui 以 html/template 渲染并自动转义(§8.3-4)。
|
以下占位符由服务端 internal/webui 以 html/template 渲染并自动转义(§8.3-4)。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router'
|
|||||||
import {
|
import {
|
||||||
Moon, Sun, Monitor, ExternalLink, House, PenLine, MoreHorizontal, Tags, Trash2, Settings, LogOut,
|
Moon, Sun, Monitor, ExternalLink, House, PenLine, MoreHorizontal, Tags, Trash2, Settings, LogOut,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Dropdown, Tooltip } from 'antd'
|
import { Dropdown, Menu, Tooltip } from 'antd'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import { useSiteTitle } from '../features/site'
|
import { useSiteTitle } from '../features/site'
|
||||||
import { useTheme } from '../lib/theme'
|
import { useTheme } from '../lib/theme'
|
||||||
@@ -202,12 +202,28 @@ const adminNav = [
|
|||||||
{ to: '/admin/settings', label: '设置', icon: Settings, end: true, match: 'exact' as const },
|
{ to: '/admin/settings', label: '设置', icon: Settings, end: true, match: 'exact' as const },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const adminMenuPathByKey: Record<string, string> = {
|
||||||
|
notes: '/admin',
|
||||||
|
new: '/admin/notes/new',
|
||||||
|
trash: '/admin/trash',
|
||||||
|
settings: '/admin/settings',
|
||||||
|
}
|
||||||
|
|
||||||
function AdminSidebar() {
|
function AdminSidebar() {
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
// 新建/编辑共用「新建笔记」高亮(设计稿:编辑页高亮新建入口)
|
// 新建/编辑共用「新建笔记」高亮(设计稿:编辑页高亮新建入口)
|
||||||
const editing = location.pathname.startsWith('/admin/notes')
|
const editing = location.pathname.startsWith('/admin/notes')
|
||||||
|
const selectedKey = editing
|
||||||
|
? 'new'
|
||||||
|
: location.pathname === '/admin'
|
||||||
|
? 'notes'
|
||||||
|
: location.pathname.startsWith('/admin/trash')
|
||||||
|
? 'trash'
|
||||||
|
: location.pathname.startsWith('/admin/settings')
|
||||||
|
? 'settings'
|
||||||
|
: ''
|
||||||
return (
|
return (
|
||||||
<aside className="pn-admin-sidebar">
|
<aside className="pn-admin-sidebar">
|
||||||
<Link to="/admin" className="pn-admin-brand">
|
<Link to="/admin" className="pn-admin-brand">
|
||||||
@@ -218,25 +234,32 @@ function AdminSidebar() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="pn-admin-section">内容</div>
|
<Menu
|
||||||
<nav className="pn-admin-nav" aria-label="内容">
|
mode="inline"
|
||||||
<NavLink to="/admin" end className="pn-admin-item">
|
className="pn-admin-menu"
|
||||||
<House size={15} /><span>笔记管理</span>
|
selectedKeys={selectedKey ? [selectedKey] : []}
|
||||||
</NavLink>
|
onClick={({ key }) => navigate(adminMenuPathByKey[key])}
|
||||||
<Link to="/admin/notes/new" className={`pn-admin-item${editing ? ' active' : ''}`} aria-current={editing ? 'page' : undefined}>
|
items={[
|
||||||
<PenLine size={15} /><span>新建笔记</span>
|
{
|
||||||
</Link>
|
key: 'g-content',
|
||||||
</nav>
|
type: 'group',
|
||||||
|
label: '内容',
|
||||||
<div className="pn-admin-section">系统</div>
|
children: [
|
||||||
<nav className="pn-admin-nav" aria-label="系统">
|
{ key: 'notes', icon: <House size={15} />, label: '笔记管理' },
|
||||||
<NavLink to="/admin/trash" className="pn-admin-item">
|
{ key: 'new', icon: <PenLine size={15} />, label: '新建笔记' },
|
||||||
<Trash2 size={15} /><span>回收站</span>
|
],
|
||||||
</NavLink>
|
},
|
||||||
<NavLink to="/admin/settings" className="pn-admin-item">
|
{
|
||||||
<Settings size={15} /><span>设置</span>
|
key: 'g-system',
|
||||||
</NavLink>
|
type: 'group',
|
||||||
</nav>
|
label: '系统',
|
||||||
|
children: [
|
||||||
|
{ key: 'trash', icon: <Trash2 size={15} />, label: '回收站' },
|
||||||
|
{ key: 'settings', icon: <Settings size={15} />, label: '设置' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="pn-admin-foot">
|
<div className="pn-admin-foot">
|
||||||
<div className="pn-admin-user">
|
<div className="pn-admin-user">
|
||||||
|
|||||||
@@ -4,11 +4,20 @@ import { api } from '../lib/api'
|
|||||||
import type { SiteSettings } from '../lib/api'
|
import type { SiteSettings } from '../lib/api'
|
||||||
|
|
||||||
export function useSiteTitle() {
|
export function useSiteTitle() {
|
||||||
return useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['site'],
|
queryKey: ['site'],
|
||||||
queryFn: () => api<SiteSettings>('/api/site'),
|
queryFn: () => api<SiteSettings>('/api/site'),
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// favicon 随站点 Logo:生产由服务端注入 index.html,此处兜底(dev 模板占位符
|
||||||
|
// 不会被渲染)并保证设置变更后 SPA 内即时生效
|
||||||
|
useEffect(() => {
|
||||||
|
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
||||||
|
if (link) link.href = data?.site_logo || '/favicon.svg'
|
||||||
|
}, [data?.site_logo])
|
||||||
|
|
||||||
|
return { data }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+54
-78
@@ -242,16 +242,16 @@ li.ant-dropdown-menu-item.pn-dd-item-active span {
|
|||||||
li.ant-dropdown-menu-item.pn-dd-item-active svg {
|
li.ant-dropdown-menu-item.pn-dd-item-active svg {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
/* 标签云(antd CheckableTag):计数用等宽字体,选中态反白 */
|
/* 标签云(antd CheckableTagGroup 单选):计数用等宽字体,选中态反白。
|
||||||
.pn-cloud-tag {
|
antd 会把 label 包进单个块级 span,行内排版由内层选择器承接 */
|
||||||
display: inline-flex;
|
.pn-cloud-tag > span {
|
||||||
}
|
|
||||||
.pn-cloud-tag a {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: inherit;
|
}
|
||||||
text-decoration: none;
|
.pn-cloud-tag svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
}
|
}
|
||||||
.pn-cloud-count {
|
.pn-cloud-count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -538,16 +538,36 @@ button.pn-login-back:hover {
|
|||||||
padding-top: 56px;
|
padding-top: 56px;
|
||||||
padding-bottom: 24px;
|
padding-bottom: 24px;
|
||||||
}
|
}
|
||||||
.pn-home-filter {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.pn-home-post {
|
.pn-home-post {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18px;
|
||||||
padding: 30px 0;
|
padding: 30px 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.pn-home-post:last-child {
|
.pn-home-post:last-child {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
/* 封面缩略图(正文第一张图,无图不渲染) */
|
||||||
|
.pn-home-cover {
|
||||||
|
flex: none;
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
.pn-home-cover img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.pn-home-post-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.pn-home-date {
|
.pn-home-date {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
@@ -640,32 +660,6 @@ button.pn-login-back:hover {
|
|||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
/* 首页标签筛选条:链接型分段导航(配色走无 layer 类规则,
|
|
||||||
避免 antd 的 <a> 重置压过 utilities 层) */
|
|
||||||
.pn-home-filter a {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: calc(var(--radius-md) - 3px);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
}
|
|
||||||
.pn-home-filter a:hover {
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
.pn-home-filter a[aria-current='page'] {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
}
|
|
||||||
.pn-home-filter a:focus-visible {
|
|
||||||
outline: 2px solid var(--primary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
/* 首页分页器已由 antd Pagination 承接(.pn-home-pager 仅保留布局行) */
|
/* 首页分页器已由 antd Pagination 承接(.pn-home-pager 仅保留布局行) */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.pn-home-content {
|
.pn-home-content {
|
||||||
@@ -673,6 +667,12 @@ button.pn-login-back:hover {
|
|||||||
}
|
}
|
||||||
.pn-home-post {
|
.pn-home-post {
|
||||||
padding: 26px 0;
|
padding: 26px 0;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.pn-home-cover {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
.pn-home-post-title {
|
.pn-home-post-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
@@ -1217,7 +1217,7 @@ button.pn-note-back:hover {
|
|||||||
============================================================ */
|
============================================================ */
|
||||||
.pn-admin-shell {
|
.pn-admin-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 256px minmax(0, 1fr);
|
grid-template-columns: 208px minmax(0, 1fr);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
@@ -1285,58 +1285,34 @@ button.pn-note-back:hover {
|
|||||||
letter-spacing: var(--tracking-tight);
|
letter-spacing: var(--tracking-tight);
|
||||||
color: var(--sidebar-foreground);
|
color: var(--sidebar-foreground);
|
||||||
}
|
}
|
||||||
.pn-admin-section {
|
/* 侧栏导航(antd Menu inline):融入 sidebar 底色,沿用窄侧栏密度 */
|
||||||
padding: 0 10px;
|
.pn-admin-menu {
|
||||||
margin-bottom: -6px;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-inline-end: 0 !important;
|
||||||
|
}
|
||||||
|
.pn-admin-menu .ant-menu-item-group-title {
|
||||||
|
padding: 8px 10px 4px;
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
|
line-height: 1;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
.pn-admin-nav {
|
.pn-admin-menu .ant-menu-item {
|
||||||
display: flex;
|
margin-inline: 6px;
|
||||||
flex-direction: column;
|
width: auto;
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.pn-admin-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
padding: 0 10px;
|
line-height: 34px;
|
||||||
border: 0;
|
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: transparent;
|
|
||||||
color: var(--sidebar-foreground);
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: left;
|
color: var(--sidebar-foreground);
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
|
||||||
}
|
}
|
||||||
.pn-admin-item:hover {
|
.pn-admin-menu .ant-menu-item svg {
|
||||||
background: var(--sidebar-accent);
|
|
||||||
color: var(--sidebar-accent-foreground);
|
|
||||||
}
|
|
||||||
.pn-admin-item.active {
|
|
||||||
background: var(--sidebar-primary);
|
|
||||||
color: var(--sidebar-primary-foreground);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.pn-admin-item svg {
|
|
||||||
width: 15px;
|
width: 15px;
|
||||||
height: 15px;
|
height: 15px;
|
||||||
flex: none;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
}
|
|
||||||
.pn-admin-item:hover svg {
|
|
||||||
color: var(--sidebar-accent-foreground);
|
|
||||||
}
|
|
||||||
.pn-admin-item.active svg {
|
|
||||||
color: var(--sidebar-primary-foreground);
|
|
||||||
}
|
}
|
||||||
.pn-admin-foot {
|
.pn-admin-foot {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1503,7 +1479,7 @@ button.pn-note-back:hover {
|
|||||||
/* 响应式 */
|
/* 响应式 */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.pn-admin-shell {
|
.pn-admin-shell {
|
||||||
grid-template-columns: 224px minmax(0, 1fr);
|
grid-template-columns: 184px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
.pn-admin-content {
|
.pn-admin-content {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ export interface NoteItem {
|
|||||||
status?: string
|
status?: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
pinned: boolean
|
pinned: boolean
|
||||||
|
/** 公开列表项的封面图(正文第一张图;无图缺省) */
|
||||||
|
cover?: string
|
||||||
deleted_at?: number | null
|
deleted_at?: number | null
|
||||||
created_at: number
|
created_at: number
|
||||||
updated_at: number
|
updated_at: number
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Eye, EyeOff, ImageUp, KeyRound, Lock, Save, Trash2 } from 'lucide-react'
|
import { Eye, EyeOff, ImageUp, KeyRound, Lock, RotateCcw, Save } from 'lucide-react'
|
||||||
import { Button, Card, Divider, Input, Select, Upload } from 'antd'
|
import { Button, Card, Divider, Input, Select, Upload } from 'antd'
|
||||||
import { api, ApiError } from '../lib/api'
|
import { api, ApiError } from '../lib/api'
|
||||||
import type { SiteSettings } from '../lib/api'
|
import type { SiteSettings } from '../lib/api'
|
||||||
@@ -145,14 +145,14 @@ export default function AdminSettings() {
|
|||||||
</Upload>
|
</Upload>
|
||||||
{logo && (
|
{logo && (
|
||||||
<Button
|
<Button
|
||||||
icon={<Trash2 size={15} />}
|
icon={<RotateCcw size={15} />}
|
||||||
onClick={() => setLogo('')}
|
onClick={() => setLogo('')}
|
||||||
>
|
>
|
||||||
清除
|
重置默认
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="pn-as-hint">显示在博客顶部站点名称前,建议正方形图片;修改后需点击「保存更改」。</p>
|
<p className="pn-as-hint">显示在博客顶部站点名称前,并用作站点 favicon,建议正方形图片;「重置默认」恢复内置图标。修改后需点击「保存更改」。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
+35
-50
@@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { ArrowRight, Pin } from 'lucide-react'
|
import { ArrowRight, Pin } from 'lucide-react'
|
||||||
import { Pagination, Tag } from 'antd'
|
import { Pagination, Tag } from 'antd'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type { NoteItem, NoteList, TagCount } from '../lib/api'
|
import type { NoteItem, NoteList } from '../lib/api'
|
||||||
import { useDocumentTitle } from '../features/site'
|
import { useDocumentTitle } from '../features/site'
|
||||||
import { formatDateISO } from '../lib/utils'
|
import { formatDateISO } from '../lib/utils'
|
||||||
|
|
||||||
@@ -16,72 +16,57 @@ function useNotes(page: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function useTags() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['tags'],
|
|
||||||
queryFn: () => api<{ tags: TagCount[] }>('/api/tags'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotePost({ note }: { note: NoteItem }) {
|
function NotePost({ note }: { note: NoteItem }) {
|
||||||
return (
|
return (
|
||||||
<article className="pn-home-post">
|
<article className="pn-home-post">
|
||||||
<time className="pn-home-date" dateTime={formatDateISO(note.published_at)}>
|
{note.cover && (
|
||||||
{note.pinned && (
|
<Link
|
||||||
<span className="pn-home-pin">
|
to={`/notes/${encodeURIComponent(note.slug)}`}
|
||||||
<Pin size={11} aria-hidden />置顶
|
className="pn-home-cover"
|
||||||
</span>
|
tabIndex={-1}
|
||||||
)}
|
aria-hidden
|
||||||
{formatDateISO(note.published_at)}
|
>
|
||||||
</time>
|
<img src={note.cover} alt="" loading="lazy" />
|
||||||
<h2 className="pn-home-post-title">
|
|
||||||
<Link to={`/notes/${encodeURIComponent(note.slug)}`}>
|
|
||||||
{note.title}
|
|
||||||
<ArrowRight className="pn-home-arrow" size={17} aria-hidden />
|
|
||||||
</Link>
|
</Link>
|
||||||
</h2>
|
|
||||||
{note.summary && <p className="pn-home-summary">{note.summary}</p>}
|
|
||||||
{note.tags.length > 0 && (
|
|
||||||
<div className="pn-home-tags">
|
|
||||||
{note.tags.map((t) => (
|
|
||||||
<Tag key={t}>
|
|
||||||
<Link className="pn-tag-link" to={`/tags/${encodeURIComponent(t)}`}>{t}</Link>
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="pn-home-post-main">
|
||||||
|
<time className="pn-home-date" dateTime={formatDateISO(note.published_at)}>
|
||||||
|
{note.pinned && (
|
||||||
|
<span className="pn-home-pin">
|
||||||
|
<Pin size={11} aria-hidden />置顶
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{formatDateISO(note.published_at)}
|
||||||
|
</time>
|
||||||
|
<h2 className="pn-home-post-title">
|
||||||
|
<Link to={`/notes/${encodeURIComponent(note.slug)}`}>
|
||||||
|
{note.title}
|
||||||
|
<ArrowRight className="pn-home-arrow" size={17} aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
|
{note.summary && <p className="pn-home-summary">{note.summary}</p>}
|
||||||
|
{note.tags.length > 0 && (
|
||||||
|
<div className="pn-home-tags">
|
||||||
|
{note.tags.map((t) => (
|
||||||
|
<Tag key={t}>
|
||||||
|
<Link className="pn-tag-link" to={`/tags/${encodeURIComponent(t)}`}>{t}</Link>
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首页标签筛选:链接型分段导航(路由跳转而非受控切换,故用原生导航标记;
|
|
||||||
// 链接配色由 .pn-home-filter a 的无 layer 规则承接,见 index.css)
|
|
||||||
const filterStripCls =
|
|
||||||
'inline-flex min-w-0 items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border)] bg-muted p-1'
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
useDocumentTitle()
|
useDocumentTitle()
|
||||||
const { data, isLoading } = useNotes(page)
|
const { data, isLoading } = useNotes(page)
|
||||||
const { data: tagsData } = useTags()
|
|
||||||
const totalPages = data ? Math.max(1, Math.ceil(data.total / data.page_size)) : 1
|
const totalPages = data ? Math.max(1, Math.ceil(data.total / data.page_size)) : 1
|
||||||
const topTags = (tagsData?.tags ?? []).slice(0, 4)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pn-home-content">
|
<div className="pn-home-content">
|
||||||
{topTags.length > 0 && (
|
|
||||||
<nav className={`pn-home-filter ${filterStripCls}`} aria-label="按标签筛选笔记">
|
|
||||||
<Link to="/" aria-current={page === 1 ? 'page' : undefined}>
|
|
||||||
全部
|
|
||||||
</Link>
|
|
||||||
{topTags.map((t) => (
|
|
||||||
<Link key={t.name} to={`/tags/${encodeURIComponent(t.name)}`}>
|
|
||||||
{t.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section aria-label="笔记列表">
|
<section aria-label="笔记列表">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="pn-home-list-empty">加载中…</p>
|
<p className="pn-home-list-empty">加载中…</p>
|
||||||
|
|||||||
+24
-18
@@ -1,4 +1,4 @@
|
|||||||
import { Link, useParams } from 'react-router'
|
import { Link, useNavigate, useParams } from 'react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { ArrowRight, Tag } from 'lucide-react'
|
import { ArrowRight, Tag } from 'lucide-react'
|
||||||
import { Tag as AntTag } from 'antd'
|
import { Tag as AntTag } from 'antd'
|
||||||
@@ -16,6 +16,7 @@ function useTags() {
|
|||||||
|
|
||||||
export default function TagPage() {
|
export default function TagPage() {
|
||||||
const { tag } = useParams()
|
const { tag } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
useDocumentTitle(tag ? `标签:${tag}` : '标签')
|
useDocumentTitle(tag ? `标签:${tag}` : '标签')
|
||||||
const { data: tagsData } = useTags()
|
const { data: tagsData } = useTags()
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
@@ -32,26 +33,31 @@ export default function TagPage() {
|
|||||||
<p>按主题浏览所有公开笔记</p>
|
<p>按主题浏览所有公开笔记</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<nav className="pn-tag-cloud" aria-label="全部标签">
|
{/* 单选 CheckableTagGroup:选中态由组件承接;再次点击已选标签 = 取消(回全部) */}
|
||||||
{tagsData?.tags.map((t) => (
|
<AntTag.CheckableTagGroup
|
||||||
<AntTag.CheckableTag key={t.name} checked={t.name === tag} className="pn-cloud-tag">
|
multiple={false}
|
||||||
<Link
|
className="pn-tag-cloud"
|
||||||
to={`/tags/${encodeURIComponent(t.name)}`}
|
value={tag ?? null}
|
||||||
aria-current={t.name === tag ? 'true' : undefined}
|
onChange={(v) => navigate(v === null ? '/tags' : `/tags/${encodeURIComponent(v)}`)}
|
||||||
>
|
options={(tagsData?.tags ?? []).map((t) => ({
|
||||||
|
value: t.name,
|
||||||
|
className: 'pn-cloud-tag',
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
<Tag size={14} />
|
<Tag size={14} />
|
||||||
<span>{t.name}</span>
|
<span>{t.name}</span>
|
||||||
<span className="pn-cloud-count">{t.count}</span>
|
<span className="pn-cloud-count">{t.count}</span>
|
||||||
</Link>
|
</>
|
||||||
</AntTag.CheckableTag>
|
),
|
||||||
))}
|
}))}
|
||||||
{!tagsData && (
|
aria-label="全部标签"
|
||||||
<span className="p-2 font-mono text-xs text-muted-foreground">加载中…</span>
|
/>
|
||||||
)}
|
{!tagsData && (
|
||||||
{tagsData && tagsData.tags.length === 0 && (
|
<span className="p-2 font-mono text-xs text-muted-foreground">加载中…</span>
|
||||||
<span className="p-2 font-mono text-xs text-muted-foreground">还没有标签</span>
|
)}
|
||||||
)}
|
{tagsData && tagsData.tags.length === 0 && (
|
||||||
</nav>
|
<span className="p-2 font-mono text-xs text-muted-foreground">还没有标签</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{tag === undefined && tagsData && (
|
{tag === undefined && tagsData && (
|
||||||
<p className="pn-home-list-empty" style={{ marginTop: 48 }}>选择一个标签,查看对应笔记</p>
|
<p className="pn-home-list-empty" style={{ marginTop: 48 }}>选择一个标签,查看对应笔记</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user