Compare commits

...

15 Commits

Author SHA1 Message Date
xfy
c1610ace99 fix(lightbox): prevent background flash on open
Some checks failed
CI / check (push) Failing after 5m16s
CI / build (push) Has been skipped
overlay inherited opacity:1 from .lightbox-overlay CSS, so the moment
it was appended to the DOM it painted a full-opacity black background.
this showed for ~24ms (the time until the image loaded and start() ran
the first frame that sets opacity 0), visible as a black flash before
the fade-in.

set overlay.style.opacity = '0' right after creating it, so it is
transparent when appended; start()'s transition then fades it to 1 as
intended.
2026-06-23 17:37:38 +08:00
xfy
8bdc3553bd fix(lightbox): correct zoom origin, scroll-close, blur-up rendering
several bugs found via in-browser debugging, all in lightbox.js + the
blur-up CSS:

1. DOMRect attribute mismatch: getBoundingClientRect returns .width/
   .height, but code read .w/.h -> NaN transforms. map rectOf() to a
   uniform {x,y,w,h}.

2. zoom scale base was the huge original natural size (2942px), so the
   centered state collapsed to a tiny scale and the open animation ran
   backwards (shrink). base the scale on originRect instead: first
   frame is the in-article image at scale 1, centered state scales to
   target.w/originRect.w. open is always grow, close always shrink.

3. scroll-to-close fired on a phantom scroll jump: the oversized
   original img (layout size = naturalW) expanded the document's
   scrollable area. set img layout size explicitly and add
   overflow:hidden on the (position:fixed) overlay so the img cannot
   expand the body.

4. first open animation jumped from the wrong position: a single rAF
   was not enough to commit the no-transition first frame. use a forced
   reflow (offsetHeight) + double-rAF before starting the transition.

5. blur-up full layer stuck at opacity 0 until a forced reflow (opening
   the lightbox) repainted it. drop the opacity transition on .blur-img-
   full and set opacity:1 from JS on load (deterministic, no repaint
   race). mark the container .is-loaded and hide the placeholder via
   the container class.

6. a 16px grey gap above in-article images: .md-content img { margin:
   1rem 0 } applied to the absolute .blur-img-full too, pushing it
   down. reset margin:0 / max-width:none on the blur-up img layers.
2026-06-23 17:30:37 +08:00
xfy
940236d84f style: unify lightbox CSS, adapt entry-cover and post-card blur
remove the duplicated .image-viewer-* and .md-image-lightbox-* rules
(~93 lines); add a single .lightbox-* set sized/positioned by JS
transform. entry-cover and post-card-cover now target the blur-img
structure (4:3 ratio on cards, cover-fill on entry cover).
2026-06-23 15:53:14 +08:00
xfy
cb8c42e833 refactor: remove obsolete ImageViewer component
both call sites (PostCover, PostCard) now render raw .blur-img, so the
Dioxus ImageViewer component — with its leaky closure.forget() and dead
js_sys::eval delay — is no longer needed. remove the file and its mod
declaration.
2026-06-23 15:51:40 +08:00
xfy
8b1d55613e refactor(post-card): use raw blur-img for cover, drop ImageViewer
card cover is display-only (click goes to the card link), so it uses
a bare .blur-img with ?w=20 placeholder + ?thumb=400x300 display, no
lightbox-single class. card pages are outside .post-content/.entry-cover
so lightbox.js never scans them.
2026-06-23 15:50:29 +08:00
xfy
75b9080df8 refactor(post-cover): use raw blur-img structure as single lightbox image
drop ImageViewer; render .blur-img with a lightbox-single class (the
class, not a data attribute, because Dioxus' typed span elements reject
arbitrary data-* attrs). lightbox.js now keys single-mode off the
lightbox-single class instead of data-single. cover uses ?w=20
placeholder + ?w=1200 display; SSR writes --ar from real dimensions.
2026-06-23 15:48:02 +08:00
xfy
f88d83b0ad feat(post-content): load lightbox.js and init gallery
eval both post-content.js (copy) and lightbox.js (lightbox + lazy-load),
then call __initLightbox over ['.post-content', '.entry-cover'] so both
in-content images (gallery) and the cover (single) are wired up.
2026-06-23 15:45:30 +08:00
xfy
bace31933f refactor(post-content): remove lightbox/lazy-load, keep copy only
the lightbox + IntersectionObserver lazy-load now live in lightbox.js;
post-content.js is reduced to just the code-block copy button.
2026-06-23 15:44:46 +08:00
xfy
5d2b6e502d feat(lightbox): gallery navigation with fade, arrows + keyboard
add prev/next arrow buttons (only in gallery mode with >1 image) and
ArrowLeft/ArrowRight keyboard handling. switching uses a 150ms
fade-out -> swap src -> fade-in (no fly, matching Medium). originNode
is updated to the new image so close/scroll-close flies back to the
right place; openScrollY is reset to keep scroll-close baseline valid.
2026-06-23 15:43:24 +08:00
xfy
ab4f006107 fix(lightbox): bind interactions immediately, before image loads
bindInteractions() was only called from start(), which waits for the
image load event. during the load window Esc/scroll/backdrop-click did
nothing. bind right after appending the overlay so the lightbox is
closable throughout the load.
2026-06-23 15:41:22 +08:00
xfy
a5c7e64230 feat(lightbox): open/close with origin-aware zoom + scroll-to-close
click a .blur-img to open: record its viewport rect, the lightbox img
starts at that position/size and transforms to centered (Medium-style
origin-aware zoom). close flies back to the live originNode rect.

scroll-to-close: while open, any window scroll drives the image back
to its (moving) position via per-frame rect tracking + linear
interpolation; the article keeps its scroll position on close.

Esc + click-on-backdrop close; click-on-image does not close.
prefers-reduced-motion falls back to fade-only / immediate close.
2026-06-23 15:29:59 +08:00
xfy
fe58d25b54 feat(lightbox): add lightbox.js skeleton with lazy-load 2026-06-23 15:28:47 +08:00
xfy
e68b77aac0 style(highlight): unify code block background via --color-paper-code-block
strip syntect-injected background-color from generated CSS so the code
block background comes from a single source (--color-paper-code-block
in input.css), avoiding double-layer color mismatch at the .code span
edge. update the variable values for both light and dark themes.
2026-06-23 15:28:12 +08:00
xfy
975e331dd5 fix(write): split tags on fullwidth punctuation
The tag splitter matched ASCII ',' and ';' twice instead of the
fullwidth ',' (U+FF0C) / ';' (U+FF1B) the comment intended, so tags
separated by fullwidth punctuation (common under Chinese IMEs)
collapsed into a single tag.

Also resolves the compiler warnings surfaced in the same build:
- tiptap_bridge: drop unused Readable/Writable imports and
  EditorInstance/TiptapEditorModule re-exports
- tiptap_bridge: migrate deprecated RequestInit::method/body/
  credentials builders to set_method/set_body/set_credentials
- write: remove redundant `mut` on Dioxus signals that are only
  read/set, never reassigned
- image_viewer: gate the server-only `mut` reassignment so the WASM
  build (which strips the cfg block) doesn't warn
2026-06-23 14:44:18 +08:00
xfy
ef6a3f0da2 fix(home,editor): repair post card nested anchors and editor upload state
首页审查修复(home/post_card/image_viewer/search/home_skeleton):
- PostCard 消除嵌套 <a>:外层 Link 包裹改为绝对定位覆盖层链接,
  标题/摘要/封面降级为普通元素,标签用 z-10 叠在覆盖层之上并
  stop_propagation,整卡点击跳转文章详情。
- ImageViewer 新增 lightbox prop(默认 true),卡片封面传 false
  禁用灯箱以归一为单一跳转交互;文章详情页零改动。
- home.rs 越界页码守卫:分页仅在 total>0 渲染,避免 /page/9999
  出现孤立空分页;错误文案脱敏为"加载失败"。
- search.rs 错误文案脱敏为"搜索失败",与首页/标签页一致。
- HomeSkeleton 卡片 5→10,对齐 POSTS_PER_PAGE。

编辑器上传重构(write/tiptap_bridge):
- consume_upload_event 改用 upload_errors Vec 自身判重,移除
  seen_error_ids 副本状态;Signal 参数加 mut 修复 E0596 编译错误。
- make_upload_closure 构造阶段错误以 rejected Promise 返回而非
  panic,单张坏文件不再拖垮整个编辑器。
- write.rs 移除未用的 success signal,编辑保存后统一跳转 Posts,
  标签分隔符支持半角/全角逗号与分号,提取 META_*_CLASS 常量去重。
- Makefile dev 目标清理 static/ 目录。
2026-06-23 14:14:28 +08:00
15 changed files with 898 additions and 499 deletions

View File

@ -27,6 +27,8 @@ build-editor-incremental:
@cd libs/tiptap-editor && npm run build
dev: build-editor-incremental
@echo "Cleaning static/..."
@rm -rf static/
@echo "Building Tiptap editor (incremental)..."
@echo "Starting tailwindcss watch and dx serve..."
@tailwindcss -i input.css -o public/style.css --watch & \

202
input.css
View File

@ -11,7 +11,7 @@
--color-paper-secondary: #78716c;
--color-paper-tertiary: #d6d3d1;
--color-paper-content: #292524;
--color-paper-code-block: #1c1917;
--color-paper-code-block: #eff1f5;
--color-paper-code-bg: #f5f4f0;
--color-paper-border: #e7e5e0;
--color-paper-accent: #5c7a5e;
@ -45,7 +45,7 @@
--color-paper-secondary: #8c8981;
--color-paper-tertiary: #3a3a3d;
--color-paper-content: #d4d2cc;
--color-paper-code-block: #1e1f22;
--color-paper-code-block: #1e1e2e;
--color-paper-code-bg: #28292d;
--color-paper-border: #2a2b2e;
--color-paper-accent: #7da97f;
@ -198,10 +198,15 @@
margin-bottom: var(--content-gap-paper);
}
.entry-cover img {
/* 封面用 blur-img 结构:去 margin、保持圆角内层 img cover 填充 */
.entry-cover .blur-img {
margin: 0;
border-radius: var(--radius-paper);
}
.entry-cover img {
width: 100%;
height: auto;
height: 100%;
object-fit: cover;
}
.post-content {
@ -521,113 +526,90 @@
text-underline-offset: 0.3rem;
}
.image-viewer-overlay {
/* ========== 统一灯箱Medium 风格) ========== */
.lightbox-overlay {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
animation: fadeIn 0.2s ease-out;
}
.image-viewer-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
}
.image-viewer-img {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius-paper);
}
.image-viewer-close {
position: absolute;
top: -2.5rem;
right: 0;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 1.5rem;
width: 2rem;
height: 2rem;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease-out;
}
.image-viewer-close:hover {
background: rgba(255, 255, 255, 0.4);
}
.md-image-lightbox-overlay {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
animation: fadeIn 0.2s ease-out;
}
.md-image-lightbox-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
}
.md-image-lightbox-content img {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius-paper);
}
.md-image-lightbox-close {
position: absolute;
top: -2.5rem;
right: 0;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 1.5rem;
width: 2rem;
height: 2rem;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease-out;
}
.md-image-lightbox-close:hover {
background: rgba(255, 255, 255, 0.4);
}
.post-card-cover {
/* overflow:hidden 裁剪超大的 absolute img防止原图可能数千 px
撑大文档可滚动区否则会触发非预期的 scroll 事件 */
overflow: hidden;
border-radius: var(--radius-paper);
background: rgba(0, 0, 0, 0.92);
opacity: 1;
}
.post-card-cover img {
/* 灯箱图片绝对定位尺寸/位置由 JS transform 控制
transform-origin top left配合 translate+scale 实现原地缩放
显式 width/height JS 设置为原图尺寸确保布局尺寸确定
transform 只做视觉缩放不改变占位 */
.lightbox-img {
position: absolute;
left: 0;
top: 0;
transform-origin: top left;
max-width: none;
max-height: none;
object-fit: contain;
opacity: 1;
}
.lightbox-caption {
position: fixed;
left: 0;
right: 0;
bottom: 2rem;
text-align: center;
color: rgba(255, 255, 255, 0.85);
font-size: 0.95rem;
padding: 0 2rem;
pointer-events: none;
z-index: 2;
}
.lightbox-counter {
position: fixed;
right: 1.5rem;
bottom: 1.5rem;
color: rgba(255, 255, 255, 0.7);
font-size: 0.85rem;
z-index: 2;
}
.lightbox-nav {
position: fixed;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.7);
font-size: 3rem;
line-height: 1;
width: 3rem;
height: 5rem;
cursor: pointer;
z-index: 2;
border-radius: 0.5rem;
transition: color 0.2s ease-out, background 0.2s ease-out;
}
.lightbox-nav:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
.lightbox-prev { left: 1rem; }
.lightbox-next { right: 1rem; }
/* 卡片封面blur-img 结构,固定 4:3 比例hover 缩放 */
.post-card-cover-blur {
margin: 0;
aspect-ratio: 4 / 3;
}
.post-card-cover-blur img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease-out;
}
.post-card-cover:hover img {
.group:hover .post-card-cover-blur img {
transform: scale(1.05);
}
@ -745,9 +727,13 @@
width: 100%;
height: 100%;
object-fit: cover;
/* 重置 .md-content img 的 margin/max-width/height避免把 absolute 图层推偏 */
margin: 0;
max-width: none;
/* 20px 占位图放大后模糊scale 遮边缘 */
filter: blur(20px) saturate(1.2);
transform: scale(1.1);
transition: opacity 0.4s ease;
}
.blur-img-full {
position: absolute;
@ -755,13 +741,25 @@
width: 100%;
height: 100%;
object-fit: cover;
/* 重置 .md-content img 的 margin/max-width/height避免把 absolute 图层推偏 */
margin: 0;
max-width: none;
opacity: 0;
transition: opacity 0.4s ease;
z-index: 1;
}
.blur-img-full.is-loaded {
/* full 加载完成后由 JS 直接设 opacity:1不依赖 CSS transition
避免合成层重绘时机导致 opacity 卡在 0 */
.blur-img-full.is-loaded,
.blur-img.is-loaded .blur-img-full {
opacity: 1;
}
/* 容器标记 loaded 后:显式隐藏占位图 + 去掉灰底,避免重绘时机导致残留/灰边 */
.blur-img.is-loaded .blur-img-placeholder {
opacity: 0;
}
.blur-img.is-loaded {
background: transparent;
}
/* 暗色模式灰底 */
.dark .blur-img {

540
public/js/lightbox.js Normal file
View File

@ -0,0 +1,540 @@
(function () {
"use strict";
// ============ 工具函数 ============
function prefersReducedMotion() {
return (
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
// 读取元素当前在视口里的 rect用于飞行起点/终点)。
// 统一映射成 {x,y,w,h}getBoundingClientRect 返回的 DOMRect 用
// left/top/width/height而 fitCentered/transformFor 用 x/y/w/h
// 这里转成一致格式,避免 .w 读到 undefined。
function rectOf(el) {
var r = el.getBoundingClientRect();
return {
x: r.left,
y: r.top,
w: r.width,
h: r.height,
};
}
// 计算图片在视口居中、contain 适配后的目标 rect。
// naturalW/H: 图片真实像素尺寸vw/vh: 视口尺寸。
function fitCentered(naturalW, naturalH, vw, vh) {
var maxW = vw * 0.92;
var maxH = vh * 0.88;
var scale = Math.min(maxW / naturalW, maxH / naturalH, 1);
var w = naturalW * scale;
var h = naturalH * scale;
return {
x: (vw - w) / 2,
y: (vh - h) / 2,
w: w,
h: h,
};
}
// 把目标 rect 转成 transform 字符串。
// baseW/baseH 是 img 元素的布局尺寸(=居中目标尺寸scale 相对它缩放。
// transform-origin 为 top left见 CSStranslate 到 rect 左上角后 scale。
// - 居中态scale=1base 就是居中尺寸)
// - originRect 态scale = originRect.w / base.w缩小
function transformFor(rect, baseW, baseH) {
var sx = baseW > 0 ? rect.w / baseW : 1;
var sy = baseH > 0 ? rect.h / baseH : 1;
return (
"translate(" +
rect.x +
"px," +
rect.y +
"px) scale(" +
sx +
"," +
sy +
")"
);
}
// 原图 URL = data-src 去 query。data-src 形如 "/uploads/x.webp?w=800"。
function originalUrl(dataSrc) {
return (dataSrc || "").split("?")[0];
}
// ============ 懒加载 ============
// 为单个 .blur-img 容器初始化高清图懒加载。
// IO 进入视口后把 data-src 写入 src加载完成加 is-loaded 触发 CSS 淡入。
function initLazyLoad(container) {
var fullImg = container.querySelector(".blur-img-full");
if (!fullImg) return;
if (container.getAttribute("data-blur-init")) return;
container.setAttribute("data-blur-init", "true");
var fullSrc = fullImg.getAttribute("data-src");
if (!fullSrc) return;
var onFullLoaded = function () {
// 给容器加 is-loadedCSS 据此显式隐藏 placeholder。
// 直接把 full 层 opacity 设为 1清掉 transition不依赖 CSS 的 opacity
// 过渡:合成层重绘时机不稳定,可能导致 full 层卡在 opacity:0直到一次
// 强制重排才更新。
container.classList.add("is-loaded");
fullImg.style.transition = "none";
fullImg.style.opacity = "1";
};
fullImg.addEventListener("load", onFullLoaded);
// 缓存兜底:若设 src 时图片已在缓存load 几乎立即触发,可能早于监听注册),
// 用 complete 补一次。注意无 src 的 img complete 也为 true故先判 src。
if (fullImg.getAttribute("src") && fullImg.complete) {
onFullLoaded();
}
if ("IntersectionObserver" in window) {
var io = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
fullImg.src = fullSrc;
io.unobserve(container);
}
});
},
{ rootMargin: "200px" }
);
io.observe(container);
} else {
fullImg.src = fullSrc;
}
}
// ============ 图像收集 ============
// 收集所有 selectors命中的 .blur-img 节点。
// gallery: 正文图组成图集singles: 带 lightbox-single class 的单张图(如封面)。
function collectImages(roots) {
var gallery = [];
var singles = [];
for (var i = 0; i < roots.length; i++) {
var nodes = roots[i].querySelectorAll(".blur-img");
for (var j = 0; j < nodes.length; j++) {
var n = nodes[j];
if (n.classList.contains("lightbox-single")) {
singles.push(n);
} else {
gallery.push(n);
}
}
}
return { gallery: gallery, singles: singles };
}
// ============ 灯箱状态与开/关 ============
// 当前灯箱状态(同时只允许一个灯箱)。
var state = null;
function openLightbox(originNode, gallery, index) {
if (state) closeLightbox(true);
var fullImg = originNode.querySelector(".blur-img-full");
if (!fullImg) return;
var dataSrc = fullImg.getAttribute("data-src") || "";
var origSrc = originalUrl(dataSrc);
var altText = fullImg.getAttribute("alt") || "";
var isSingle =
originNode.classList.contains("lightbox-single") ||
gallery.length === 0;
var vw = window.innerWidth;
var vh = window.innerHeight;
// 建 DOM
var overlay = document.createElement("div");
overlay.className = "lightbox-overlay";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-label", "图片预览");
overlay.setAttribute("tabindex", "-1");
// 创建后立即设 opacity 0append 到 DOM 时是透明的,避免在图片加载期间
// start() 执行前显示全黑背景造成闪烁。start() 的渐变会把 opacity 升到 1。
overlay.style.opacity = "0";
var img = document.createElement("img");
img.className = "lightbox-img";
img.setAttribute("alt", altText);
// 加载前先占 0 尺寸,避免原图(可能数千 px在加载期间撑大文档
// 可滚动区、触发非预期的 scroll 事件。start() 拿到 natural 尺寸后再设真实值。
img.style.width = "0px";
img.style.height = "0px";
var caption = document.createElement("figcaption");
caption.className = "lightbox-caption";
caption.textContent = altText;
if (!altText) caption.style.display = "none";
var counter = document.createElement("div");
counter.className = "lightbox-counter";
if (isSingle || gallery.length === 0) {
counter.style.display = "none";
} else {
counter.textContent = index + 1 + " / " + gallery.length;
}
// 图集模式(>1 张)才加左右导航箭头;单张不显示。
var prevBtn = null;
var nextBtn = null;
if (!isSingle && gallery.length > 1) {
prevBtn = document.createElement("button");
prevBtn.className = "lightbox-nav lightbox-prev";
prevBtn.setAttribute("type", "button");
prevBtn.setAttribute("aria-label", "上一张");
prevBtn.textContent = "\u2039";
nextBtn = document.createElement("button");
nextBtn.className = "lightbox-nav lightbox-next";
nextBtn.setAttribute("type", "button");
nextBtn.setAttribute("aria-label", "下一张");
nextBtn.textContent = "\u203a";
}
overlay.appendChild(img);
overlay.appendChild(caption);
overlay.appendChild(counter);
if (prevBtn) overlay.appendChild(prevBtn);
if (nextBtn) overlay.appendChild(nextBtn);
document.body.appendChild(overlay);
state = {
overlay: overlay,
img: img,
caption: caption,
counter: counter,
prevBtn: prevBtn,
nextBtn: nextBtn,
originNode: originNode,
gallery: gallery,
index: index,
isSingle: isSingle,
openScrollY: window.scrollY,
origSrc: origSrc,
altText: altText,
closing: false,
reduced: prefersReducedMotion(),
scrollHandler: null,
keyHandler: null,
};
// 焦点移入灯箱
overlay.focus();
// 立即绑定交互(不等图片加载):加载期间 Esc/滚动/点背景也须可关闭。
bindInteractions();
// 图片加载后再做动画naturalW/H 要等加载)
var start = function () {
if (!state) return; // 加载前可能已被关闭
var naturalW = img.naturalWidth || img.clientWidth || 1;
var naturalH = img.naturalHeight || img.clientHeight || 1;
var originRect = rectOf(originNode);
// 基准 = originRect文章里图片的实际尺寸
// img 的布局尺寸固定为 originRecttransform 的 scale 相对它缩放:
// 首帧文章位置scale=1居中态 scale=target.w/originRect.w。
// 这样无论 target 比 originRect 大或小,动画都是「从文章图原样连续缩放」,
// 视觉上是原地展开不会像「从外面飞来」。灯箱图尺寸恒为视口最大fitCentered
var target = fitCentered(naturalW, naturalH, vw, vh);
var baseW = originRect.w;
var baseH = originRect.h;
// 存基准与目标,供关闭/滚动关闭复用同一对(保证 scale 连续)。
state.target = target;
state.baseW = baseW;
state.baseH = baseH;
img.style.width = baseW + "px";
img.style.height = baseH + "px";
// reduced-motion直接淡入居中
if (state.reduced) {
img.style.opacity = "0";
img.style.transform = transformFor(target, baseW, baseH);
img.style.left = "0";
img.style.top = "0";
overlay.style.opacity = "0";
// 下一帧淡入
requestAnimationFrame(function () {
if (!state) return;
overlay.style.transition = "opacity 200ms ease-out";
img.style.transition = "opacity 200ms ease-out";
overlay.style.opacity = "1";
img.style.opacity = "1";
});
return;
}
// 首帧:文章位置 + 原尺寸scale=1透明且关闭 transition
img.style.transition = "none";
img.style.left = "0";
img.style.top = "0";
img.style.transform = transformFor(originRect, baseW, baseH);
img.style.opacity = "0";
overlay.style.opacity = "0";
// 强制 reflow确保首帧的 transform 已提交到渲染层。
// 否则单层 rAF 里浏览器可能合并首帧与目标帧,动画从错误位置起跳。
void img.offsetHeight;
// double-rAF第一帧绘制首帧无动画第二帧才启动 transition 到居中。
requestAnimationFrame(function () {
if (!state) return;
requestAnimationFrame(function () {
if (!state) return;
img.style.transition =
"transform 250ms ease-out, opacity 250ms ease-out";
overlay.style.transition = "opacity 250ms ease-out";
img.style.transform = transformFor(target, baseW, baseH);
img.style.opacity = "1";
overlay.style.opacity = "1";
});
});
};
if (img.complete && img.naturalWidth) {
start();
} else {
img.addEventListener("load", start, { once: true });
}
img.src = origSrc;
}
function closeLightbox(immediate) {
if (!state || state.closing) return;
state.closing = true;
cleanupInteractions();
var s = state;
// 基准 = originRect 尺寸与打开时一致scale 相对它缩放。
var baseW = s.baseW || (s.target ? s.target.w : 1);
var baseH = s.baseH || (s.target ? s.target.h : 1);
var originRect = rectOf(s.originNode); // 实时读,处理期间滚动过的情况
if (s.reduced || immediate) {
removeOverlay();
return;
}
// 飞回 originRectscale 从 1 缩到 originRect.w/baseW
s.img.style.transition =
"transform 250ms ease-out, opacity 250ms ease-out";
s.overlay.style.transition = "opacity 250ms ease-out";
s.img.style.transform = transformFor(originRect, baseW, baseH);
s.img.style.opacity = "0";
s.overlay.style.opacity = "0";
var done = function () {
removeOverlay();
};
// 250ms 兜底,避免 transitionend 不触发
var timer = setTimeout(done, 280);
s.img.addEventListener("transitionend", function () { clearTimeout(timer); done(); }, { once: true });
}
function removeOverlay() {
if (!state) return;
var prev = state.originNode;
if (state.overlay && state.overlay.parentNode) {
state.overlay.parentNode.removeChild(state.overlay);
}
state = null;
// 焦点归还:.blur-img 是 span 不可聚焦,让其内部 full img 获得焦点
if (prev) {
var f = prev.querySelector(".blur-img-full");
if (f) {
f.setAttribute("tabindex", "-1");
f.focus();
}
}
}
// ============ 图集切换 ============
// 图集切换淡入淡出不飞行。newIndex 循环(首尾衔接)。
function gotoIndex(newIndex) {
if (!state || state.isSingle) return;
var s = state;
if (!s.gallery || s.gallery.length === 0) return;
if (newIndex < 0) newIndex = s.gallery.length - 1;
if (newIndex >= s.gallery.length) newIndex = 0;
if (newIndex === s.index) return;
var newNode = s.gallery[newIndex];
var fullImg = newNode.querySelector(".blur-img-full");
if (!fullImg) return;
var origSrc = originalUrl(fullImg.getAttribute("data-src") || "");
var altText = fullImg.getAttribute("alt") || "";
// 淡出当前图
s.img.style.transition = "opacity 150ms ease-out";
s.img.style.opacity = "0";
// 150ms 后换图淡入
var swap = function () {
if (!state) return; // 切换中可能已关闭
var fade = function () {
if (!state) return;
s.img.style.transition = "opacity 150ms ease-out";
s.img.style.opacity = "1";
};
if (s.img.complete && s.img.naturalWidth) {
// 缓存命中,直接淡入
s.img.src = origSrc;
fade();
} else {
s.img.addEventListener("load", fade, { once: true });
s.img.src = origSrc;
}
s.caption.textContent = altText;
s.caption.style.display = altText ? "" : "none";
s.counter.textContent = newIndex + 1 + " / " + s.gallery.length;
// 更新 originNode 为新图,使后续关闭/滚动关闭飞回新图位置
s.originNode = newNode;
s.index = newIndex;
s.openScrollY = window.scrollY; // 重置滚动关闭基线
};
setTimeout(swap, 150);
}
// ============ 交互绑定 ============
function bindInteractions() {
var s = state;
if (!s) return;
// 点背景关闭(点图片本身不关,因箭头在图上、避免误关)
s.overlay.addEventListener("click", function (ev) {
if (state && ev.target === state.overlay) closeLightbox(false);
});
// 滚动驱动关闭:任何 scroll 都触发,用 scrollY 偏移算进度。
// 关键:逐帧读 originNode 实时 rect文章滚多少图就回多少。
s.scrollHandler = function () {
if (!state) return;
var st = state;
if (st.closing) return;
var dy = Math.abs(window.scrollY - st.openScrollY);
if (st.reduced) {
// reduced-motion立即关
closeLightbox(true);
return;
}
var progress = Math.min(dy / 120, 1);
var target = st.target;
var baseW = st.baseW || (target ? target.w : 1);
var baseH = st.baseH || (target ? target.h : 1);
var originRect = rectOf(st.originNode);
// 在 originRect 与居中 target 之间按 progress 线性插值
var cur = {
x: target.x + (originRect.x - target.x) * progress,
y: target.y + (originRect.y - target.y) * progress,
w: target.w + (originRect.w - target.w) * progress,
h: target.h + (originRect.h - target.h) * progress,
};
st.img.style.transition = "none";
st.img.style.transform = transformFor(cur, baseW, baseH);
st.img.style.opacity = String(1 - progress);
st.overlay.style.opacity = String(1 - progress);
if (progress >= 1) {
// 已飞回原位:文章停在当前滚动位置,移除灯箱
st.closing = true;
cleanupInteractions();
removeOverlay();
}
};
window.addEventListener("scroll", s.scrollHandler, { passive: true });
// 键盘Esc 关;图集模式 ←→ 切换
s.keyHandler = function (ev) {
if (!state) return;
if (ev.key === "Escape") {
closeLightbox(false);
} else if (!state.isSingle && state.gallery.length > 1) {
if (ev.key === "ArrowLeft") {
ev.preventDefault();
gotoIndex(state.index - 1);
} else if (ev.key === "ArrowRight") {
ev.preventDefault();
gotoIndex(state.index + 1);
}
}
};
document.addEventListener("keydown", s.keyHandler);
// 图集导航箭头点击stopPropagation 防止冒泡到 overlay 触发关闭)
if (s.prevBtn) {
s.prevBtn.addEventListener("click", function (ev) {
ev.stopPropagation();
if (state) gotoIndex(state.index - 1);
});
}
if (s.nextBtn) {
s.nextBtn.addEventListener("click", function (ev) {
ev.stopPropagation();
if (state) gotoIndex(state.index + 1);
});
}
}
function cleanupInteractions() {
if (!state) return;
if (state.scrollHandler) {
window.removeEventListener("scroll", state.scrollHandler);
state.scrollHandler = null;
}
if (state.keyHandler) {
document.removeEventListener("keydown", state.keyHandler);
state.keyHandler = null;
}
}
// ============ 初始化入口 ============
window.__initLightbox = function (selectors) {
// selectors 可以是字符串、字符串数组
var sels = Array.isArray(selectors) ? selectors : [selectors];
var roots = [];
for (var i = 0; i < sels.length; i++) {
var found = document.querySelectorAll(sels[i]);
for (var j = 0; j < found.length; j++) roots.push(found[j]);
}
// 先对所有图片做懒加载(图集与单张都做)
var collected = collectImages(roots);
var everyone = collected.gallery.concat(collected.singles);
for (var k = 0; k < everyone.length; k++) {
initLazyLoad(everyone[k]);
}
// 正文图:带 index
var gallery = collected.gallery;
for (var g = 0; g < gallery.length; g++) {
(function (node, idx) {
node.addEventListener("click", function (e) {
e.preventDefault();
openLightbox(node, gallery, idx);
});
})(gallery[g], g);
}
// 单张图封面index = nullgallery 传空数组表示单张
for (var si = 0; si < collected.singles.length; si++) {
(function (node) {
node.addEventListener("click", function (e) {
e.preventDefault();
openLightbox(node, [], null);
});
})(collected.singles[si]);
}
};
})();

View File

@ -40,101 +40,9 @@
}
}
function closeLightbox(overlay) {
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
document.body.style.overflow = "";
}
}
function initImageZoom(root) {
var containers = root.querySelectorAll(".blur-img");
for (var i = 0; i < containers.length; i++) {
var container = containers[i];
if (container.getAttribute("data-blur-init")) continue;
container.setAttribute("data-blur-init", "true");
var fullImg = container.querySelector(".blur-img-full");
if (!fullImg) continue;
var fullSrc = fullImg.getAttribute("data-src");
if (!fullSrc) continue;
// 加载高清图onload 后加 is-loaded 触发 CSS opacity 淡入
fullImg.addEventListener("load", function () {
this.classList.add("is-loaded");
});
// 懒加载:进入视口才设 src
if ("IntersectionObserver" in window) {
var io = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
fullImg.src = fullSrc;
io.unobserve(container);
}
});
},
{ rootMargin: "200px" }
);
io.observe(container);
} else {
// 不支持 IO直接加载
fullImg.src = fullSrc;
}
// 灯箱:点击用高清图 URL 放大
(function (src, altText) {
container.addEventListener("click", function (e) {
e.preventDefault();
var overlay = document.createElement("div");
overlay.className = "md-image-lightbox-overlay";
var containerEl = document.createElement("div");
containerEl.className = "md-image-lightbox-content";
var bigImg = document.createElement("img");
bigImg.src = src;
bigImg.alt = altText;
var closeBtn = document.createElement("button");
closeBtn.className = "md-image-lightbox-close";
closeBtn.textContent = "\u2715";
containerEl.appendChild(bigImg);
containerEl.appendChild(closeBtn);
overlay.appendChild(containerEl);
document.body.appendChild(overlay);
document.body.style.overflow = "hidden";
var onKey = function (ev) {
if (ev.key === "Escape") {
cleanup(overlay, onKey);
}
};
var cleanup = function (ol, kh) {
closeLightbox(ol);
document.removeEventListener("keydown", kh);
};
overlay.addEventListener("click", function () {
cleanup(overlay, onKey);
});
containerEl.addEventListener("click", function (ev) {
ev.stopPropagation();
});
closeBtn.addEventListener("click", function () {
cleanup(overlay, onKey);
});
document.addEventListener("keydown", onKey);
});
})(fullSrc, fullImg.getAttribute("alt") || "");
}
}
window.__initPostContent = function (selector) {
var root = document.querySelector(selector);
if (!root) return;
initCopyButtons(root);
initImageZoom(root);
};
})();

View File

@ -16,6 +16,10 @@
//! - 深色主题:`.dark {base} { ... }` 与 `.dark {base} .kw { ... }`
//! - 其中 `{base}` 为 `.md-content pre code`,保证只影响正文代码块;
//! - `.code` 是 syntect 的根作用域特殊类,只映射到 base 容器本身。
//!
//! 背景色策略syntect 输出的 `background-color` 会被剥离,代码块背景统一由
//! `input.css` 中的 `--color-paper-code-block` 变量提供(亮色浅、暗色深),
//! 避免 `.code` span 与外层 `pre` 双层背景叠加导致的边缘色差。
use syntect::highlighting::ThemeSet;
use syntect::html::{css_for_theme_with_class_style, ClassStyle};
@ -100,6 +104,38 @@ fn minify_css(css: &str) -> String {
out.trim().to_string()
}
/// 从 CSS 规则体中移除 `background-color` 声明。
///
/// syntect 会为根作用域 `.code` 注入主题底色(如 Latte `#eff1f5`),这与
/// 外层 `pre` 的 `--color-paper-code-block` 背景叠加会形成色差。这里只保留
/// 前景色相关声明,让背景由单一来源控制。
fn strip_background_color(body: &str) -> String {
body.split(';')
.filter(|decl| {
let trimmed = decl.trim();
!trimmed.is_empty()
&& !trimmed
.to_ascii_lowercase()
.starts_with("background-color")
})
.collect::<Vec<_>>()
.join(";")
}
/// 剥离 CSS 字符串开头的 `/* ... */` 注释块(含其后的空白)。
///
/// syntect 输出的第一条规则选择器前会带主题头注释,这里在 `.code` 特判前
/// 先清掉它,使根作用域类能被正确识别并映射到 base 容器本身。
fn strip_leading_comment(s: &str) -> &str {
let trimmed = s.trim_start();
if trimmed.starts_with("/*") {
if let Some(end) = trimmed.find("*/") {
return trimmed[end + 2..].trim_start();
}
}
s
}
/// 将 syntect 生成的 CSS 选择器包装到指定的 base 选择器下,并可附加前缀。
///
/// syntect 原始输出形如:
@ -133,6 +169,10 @@ fn rewrite_rules(css: &str, base: &str, prefix: &str) -> String {
pos += open + 1;
continue;
}
// syntect 会把主题头注释(/* theme ... */)粘在第一条规则的选择器前。
// 注释虽在后续 minify 阶段会去除,但此处的 .code 特判发生在 minify 之前,
// 所以这里先剥掉前导注释块,避免根作用域类匹配失败。
let selector = strip_leading_comment(selector);
// 查找配对的右大括号,处理可能的嵌套 {}
let after_open = pos + open + 1;
@ -154,6 +194,13 @@ fn rewrite_rules(css: &str, base: &str, prefix: &str) -> String {
continue;
}
// 剥离 syntect 注入的 background-color代码块背景改由
// `--color-paper-code-block` 变量统一提供(见 input.css
let body = strip_background_color(body);
if body.is_empty() {
continue;
}
// .code 是 syntect 用于根作用域的特殊类,直接映射到 base 容器
if selector == ".code" {
out.push_str(&format!("{}{} {{\n{}\n}}\n\n", prefix, base, body));

View File

@ -1,155 +0,0 @@
//! 图片查看器组件
//!
//! 提供缩略图展示与点击放大后的全屏灯箱lightbox查看
//! 支持自定义缩略图参数、alt 文本与懒加载。
//! 灯箱支持键盘操作Escape 关闭、Tab 在关闭按钮与图片间循环。
use dioxus::prelude::*;
/// 图片查看器组件。
///
/// Props
/// - `src`:原图 URL
/// - `thumb_params`:缩略图 URL 参数,默认 `"?w=800"`
/// - `alt`:图片替代文本,默认 `"图片"`
/// - `lazy_load`:是否启用懒加载,默认 `false`
///
/// 关键事件:
/// - 点击缩略图:打开全屏灯箱
/// - 点击遮罩或关闭按钮:关闭灯箱
/// - 点击灯箱内容区:阻止事件冒泡,避免误关闭
/// - 键盘 Escape关闭灯箱
#[component]
pub fn ImageViewer(
src: String,
#[props(default = "?w=800".to_string())] thumb_params: String,
#[props(default = "?w=20".to_string())] placeholder_params: String,
#[props(default = "图片".to_string())] alt: String,
#[props(default = false)] lazy_load: bool,
) -> Element {
let mut is_open = use_signal(|| false);
// 打开灯箱时聚焦关闭按钮,并监听 Escape 键关闭。
#[cfg(target_arch = "wasm32")]
{
use wasm_bindgen::{closure::Closure, JsCast};
use_effect(move || {
if !is_open() {
return;
}
wasm_bindgen_futures::spawn_local(async move {
// 下一帧再聚焦,确保 DOM 已渲染。
let _ = js_sys::eval("new Promise(r => requestAnimationFrame(r))").unwrap();
if let Some(btn) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.query_selector(".image-viewer-close").ok())
.flatten()
.and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok())
{
let _ = btn.focus();
}
});
let closure = Closure::wrap(Box::new(move |e: web_sys::KeyboardEvent| {
if e.key() == "Escape" {
is_open.set(false);
}
}) as Box<dyn FnMut(_)>);
if let Some(window) = web_sys::window() {
let _ = window.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
}
closure.forget();
});
}
// 计算 aspect-ratioSSR 时读图片真实尺寸。WASM 端不读(--ar 已在 SSR 写入 HTML
// 非 /uploads/ 的外链图或读不到尺寸时不设 --ar。
let ar_style = {
let mut s = String::new();
#[cfg(feature = "server")]
{
if let Some(rel) = src
.strip_prefix("/uploads/")
.map(|p| p.split('?').next().unwrap_or(p))
{
if let Some((w, h)) = crate::api::image::get_image_dimensions(rel) {
// 注意CSS aspect-ratio 用斜杠分隔width / height不是冒号
s = format!("--ar:{} / {};", w, h);
}
}
}
s
};
// 拼接占位图 URL 和高清图 URL
let placeholder_src = if src.contains('?') {
format!(
"{}&{}",
src.split('?').next().unwrap_or(&src),
placeholder_params.trim_start_matches('?')
)
} else {
format!("{}{}", src, placeholder_params)
};
let full_src = if src.contains('?') {
format!(
"{}&{}",
src.split('?').next().unwrap_or(&src),
thumb_params.trim_start_matches('?')
)
} else {
format!("{}{}", src, thumb_params)
};
rsx! {
// blur-up 双层:底层占位图 + 上层高清图data-src 由前端 JS 懒加载)
span {
class: "blur-img",
style: "{ar_style}",
onclick: move |_| is_open.set(true),
img {
class: "blur-img-placeholder",
src: "{placeholder_src}",
alt: "{alt}",
loading: if lazy_load { "lazy" } else { "eager" },
}
img {
class: "blur-img-full",
"data-src": "{full_src}",
alt: "{alt}",
}
}
// 全屏灯箱
if is_open() {
div {
class: "image-viewer-overlay",
role: "dialog",
aria_modal: "true",
aria_label: "图片预览",
onclick: move |_| is_open.set(false),
div {
class: "image-viewer-content",
onclick: move |evt: dioxus::events::MouseEvent| evt.stop_propagation(),
img {
class: "image-viewer-img",
src: "{src}",
alt: "{alt}",
tabindex: 0,
}
button {
class: "image-viewer-close",
r#type: "button",
aria_label: "关闭图片预览",
onclick: move |_| is_open.set(false),
""
}
}
}
}
}
}

View File

@ -3,7 +3,7 @@
//! 提供 Dioxus UI 组件,供前端页面(`src/pages/`)使用。
//! 包括布局(`frontend_layout`、`admin_layout`)、导航(`header`、`nav`、`footer`)、
//! 文章展示(`post`、`post_card`)、评论(`comments`)、骨架屏(`skeletons`)、
//! 表单控件(`forms`以及图片查看器(`image_viewer`等共享组件。
//! 表单控件(`forms`等共享组件。
/// 后台布局组件。
pub mod admin_layout;
@ -19,8 +19,6 @@ pub mod forms;
pub mod frontend_layout;
/// 顶部导航栏组件。
pub mod header;
/// 图片查看器组件。
pub mod image_viewer;
/// 导航组件。
pub mod nav;
/// 文章详情组件。

View File

@ -10,14 +10,17 @@ use dioxus::prelude::*;
/// - `content_html`:服务端渲染的文章 HTML 字符串
///
/// 关键行为:
/// - 在 `target_arch = "wasm32"` 环境下执行 `post-content.js` 并调用初始化函数,
/// 用于处理代码块、图片点击等前端交互
/// - 在 `target_arch = "wasm32"` 环境下执行 `post-content.js`(代码块复制)与
/// `lightbox.js`(图片灯箱 + 懒加载),并调用各自的初始化函数。
#[component]
pub fn PostContent(content_html: String) -> Element {
#[cfg(target_arch = "wasm32")]
use_effect(move || {
let _ = js_sys::eval(include_str!("../../../public/js/post-content.js"));
let _ = js_sys::eval(include_str!("../../../public/js/lightbox.js"));
let _ = js_sys::eval("window.__initPostContent('.post-content')");
// 正文图组成图集;封面(.entry-cover带 lightbox-single class单张模式。
let _ = js_sys::eval("window.__initLightbox(['.post-content', '.entry-cover'])");
});
rsx! {

View File

@ -1,26 +1,66 @@
//! 文章封面组件
//!
//! 在文章详情页渲染封面大图,使用图片查看器支持点击放大。
//! 在文章详情页渲染封面大图,使用 blur-up 双层结构与 lightbox.js 灯箱。
//! 封面为单张data-single不参与正文图集切换。
use dioxus::prelude::*;
use crate::components::image_viewer::ImageViewer;
/// 文章封面组件。
///
/// Props
/// - `src`:封面原图 URL
///
/// 使用 1200px 宽度的缩略图作为默认封面展示,点击可放大查看。
/// 渲染 `.blur-img` 结构,与正文图片一致;`data-single="true"` 标记为单张,
/// 由 `lightbox.js` 接管点击放大(原地缩放飞出 + 原图展示)。
/// 服务端读取真实尺寸写入 `--ar`,确保占位期间维持正确宽高比。
#[component]
pub fn PostCover(src: String) -> Element {
// SSR 时读真实尺寸算 --arWASM 端不读HTML 已在 SSR 写入)。
let ar_style = {
#[cfg_attr(not(feature = "server"), allow(unused_mut))]
let mut s = String::new();
#[cfg(feature = "server")]
{
if let Some(rel) = src
.strip_prefix("/uploads/")
.map(|p| p.split('?').next().unwrap_or(p))
{
if let Some((w, h)) = crate::api::image::get_image_dimensions(rel) {
// CSS aspect-ratio 用斜杠分隔width / height
s = format!("--ar:{} / {};", w, h);
}
}
}
s
};
// 占位图 ?w=20展示图 ?w=1200灯箱原图由 lightbox.js 去 query 得到。
let placeholder_src = if src.contains('?') {
format!("{}&w=20", src.split('?').next().unwrap_or(&src))
} else {
format!("{}?w=20", src)
};
let full_src = if src.contains('?') {
format!("{}&w=1200", src.split('?').next().unwrap_or(&src))
} else {
format!("{}?w=1200", src)
};
rsx! {
figure { class: "entry-cover",
ImageViewer {
src: src.clone(),
thumb_params: "?w=1200",
alt: "封面图片".to_string(),
lazy_load: false,
span {
class: "blur-img entry-cover-blur lightbox-single",
style: "{ar_style}",
img {
class: "blur-img-placeholder",
src: "{placeholder_src}",
alt: "封面图片",
}
img {
class: "blur-img-full",
"data-src": "{full_src}",
alt: "封面图片",
}
}
}
}

View File

@ -5,7 +5,6 @@
use dioxus::prelude::*;
use dioxus::router::components::Link;
use crate::components::image_viewer::ImageViewer;
use crate::models::post::PostListItem;
use crate::router::Route;
@ -15,62 +14,77 @@ use crate::router::Route;
/// - `post`:文章数据模型
///
/// 展示内容包括:
/// - 封面图(如有,使用 400x300 缩略图
/// - 封面图(如有,使用 400x300 缩略图,不启用灯箱
/// - 文章标题
/// - 摘要(最多两行)
/// - 发布日期与标签
///
/// 关键事件:
/// - 点击标签时阻止事件冒泡,避免触发整卡跳转
/// 交互模型(采用覆盖层链接,避免 `<a>` 嵌套 `<a>` 的非法 HTML
/// - 整张卡片可点击跳转到文章详情:通过末尾一个绝对定位、铺满卡片的覆盖层 `Link` 实现。
/// - 标签是独立的 `Link`,通过 `relative z-10` 叠在覆盖层之上,并 `stop_propagation`
/// 点击标签进入标签详情页而不触发卡片跳转。
/// - 封面用裸 `.blur-img`(纯展示,无灯箱),点击走卡片跳转,避免交互歧义。
#[component]
pub fn PostCard(post: PostListItem) -> Element {
let post_slug = post.slug.clone();
let date_str = post.formatted_date();
let cover_src = post.cover_image.clone().unwrap_or_default();
let has_cover = post.cover_image.is_some();
rsx! {
article {
class: "relative mb-6 p-6 bg-paper-entry rounded-lg border border-paper-border hover:-translate-y-0.5 hover:border-paper-accent/50 hover:shadow-sm transition-all duration-200",
Link {
class: "block group",
to: Route::PostDetail { slug: post_slug },
if has_cover {
class: "group relative mb-6 p-6 bg-paper-entry rounded-lg border border-paper-border hover:-translate-y-0.5 hover:border-paper-accent/50 hover:shadow-sm transition-all duration-200",
if has_cover {
div {
class: "mb-4 -mx-6 -mt-6 overflow-hidden rounded-t-lg",
div {
class: "mb-4 -mx-6 -mt-6 overflow-hidden rounded-t-lg",
ImageViewer {
src: post.cover_image.clone().unwrap_or_default(),
thumb_params: "?thumb=400x300",
alt: post.title.clone(),
lazy_load: true,
class: "blur-img post-card-cover-blur",
img {
class: "blur-img-placeholder",
src: "{cover_src}?w=20",
alt: "{post.title}",
loading: "lazy",
}
img {
class: "blur-img-full is-loaded",
src: "{cover_src}?thumb=400x300",
alt: "{post.title}",
}
}
}
h2 {
class: "text-2xl font-bold leading-tight text-paper-primary group-hover:text-paper-accent transition-colors duration-200",
"{post.title}"
}
div {
class: "mt-2 text-sm text-paper-secondary leading-relaxed line-clamp-2",
"{post.summary.as_deref().unwrap_or_default()}"
}
div {
class: "mt-3 flex items-center gap-3 text-[13px] text-paper-secondary",
span { "{date_str}" }
if !post.tags.is_empty() {
span { "·" }
for tag in post.tags.clone().into_iter() {
span {
Link {
class: "hover:text-paper-accent transition-colors duration-200",
to: Route::TagDetail { tag: tag.clone() },
onclick: move |evt: dioxus::events::MouseEvent| evt.stop_propagation(),
"{tag}"
}
}
h2 {
class: "text-2xl font-bold leading-tight text-paper-primary group-hover:text-paper-accent transition-colors duration-200",
"{post.title}"
}
div {
class: "mt-2 text-sm text-paper-secondary leading-relaxed line-clamp-2",
"{post.summary.as_deref().unwrap_or_default()}"
}
div {
class: "mt-3 flex items-center gap-3 text-[13px] text-paper-secondary",
span { "{date_str}" }
if !post.tags.is_empty() {
span { "·" }
for tag in post.tags.clone().into_iter() {
span {
// 标签叠在覆盖链接之上,点击进入标签详情页而非文章详情。
Link {
class: "relative z-10 hover:text-paper-accent transition-colors duration-200",
to: Route::TagDetail { tag: tag.clone() },
onclick: move |evt: dioxus::events::MouseEvent| evt.stop_propagation(),
"{tag}"
}
}
}
}
}
// 覆盖层链接铺满卡片承担整卡跳转。z-0 位于标签 (z-10) 之下。
Link {
class: "absolute inset-0 z-0",
aria_label: "post link to {post.title}",
to: Route::PostDetail { slug: post_slug },
}
}
}
}

View File

@ -9,13 +9,14 @@ use crate::components::skeletons::post_card_skeleton::PostCardSkeleton;
/// 首页骨架屏组件。
///
/// 显示 5 个文章卡片骨架与分页按钮占位。
/// 显示与 `POSTS_PER_PAGE` 等量的文章卡片骨架与分页按钮占位,
/// 使骨架屏与加载完成后的实际列表长度一致,避免内容跳变。
#[component]
pub fn HomeSkeleton() -> Element {
rsx! {
div {
// 5 个文章卡片骨架
for _ in 0..5 {
// 10 个文章卡片骨架,对齐首页 POSTS_PER_PAGE。
for _ in 0..10 {
PostCardSkeleton {}
}
// 分页按钮占位

View File

@ -28,7 +28,7 @@ use wasm_bindgen::closure::Closure;
///
/// 内部委托给 `write_editor`,以 `None` 表示新建模式。
#[component]
#[allow(unused_mut, unused_variables)]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
pub fn Write() -> Element {
write_editor(None)
}
@ -37,7 +37,7 @@ pub fn Write() -> Element {
///
/// `id` 为要编辑的文章 ID内部委托给 `write_editor` 加载现有数据。
#[component]
#[allow(unused_mut, unused_variables)]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
pub fn WriteEdit(id: i32) -> Element {
write_editor(Some(id))
}
@ -50,7 +50,7 @@ pub fn WriteEdit(id: i32) -> Element {
/// - 编辑模式下将 Markdown 内容回填到编辑器;
/// - 提交时读取编辑器 Markdown、校验并调用 create_post / update_post
/// - 组件卸载时销毁 Tiptap 实例EditorHandle::drop 自动 destroy + 释放 closure
#[allow(unused_mut, unused_variables)]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
fn write_editor(post_id: Option<i32>) -> Element {
let is_edit = post_id.is_some();
@ -66,7 +66,6 @@ fn write_editor(post_id: Option<i32>) -> Element {
let mut loading = use_signal(|| true);
let mut saving = use_signal(|| false);
let mut error = use_signal(|| None::<String>);
let mut success = use_signal(|| false);
let mut editor_content_set = use_signal(|| false);
let mut has_backfilled = use_signal(|| false);
let mut load_error = use_signal(|| None::<String>);
@ -79,16 +78,11 @@ fn write_editor(post_id: Option<i32>) -> Element {
let mut editor: Signal<Option<EditorHandle>> = use_signal(|| None);
// WASM 前端编辑器就绪标志onReady 回调驱动,替代 __tiptap_ready 轮询)。
#[cfg(target_arch = "wasm32")]
let mut ready = use_signal(|| false);
let ready = use_signal(|| false);
// 上传状态:当前进行中计数(保存拦截)+ 顶部失败提示堆叠(用户手动关闭)
let mut uploads_in_flight = use_signal(UploadsInFlight::default);
let mut upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
// 已展示过错误的上传 id去重 + 重试后再失败时原地更新)。
// 用 signal 而非局部变量closure 的 FnMut 闭包要求可重复调用,
// signal 是 Copy 可被多次 move。
let mut seen_error_ids: Signal<std::collections::HashSet<String>> =
use_signal(std::collections::HashSet::new);
let uploads_in_flight = use_signal(UploadsInFlight::default);
let upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
// 编辑模式:文章数据加载完成后,将字段回填到表单信号。
use_effect(move || {
@ -161,11 +155,10 @@ fn write_editor(post_id: Option<i32>) -> Element {
});
let on_image_upload = crate::tiptap_bridge::make_upload_closure();
let on_upload_event = Closure::new({
let mut uploads_in_flight = uploads_in_flight;
let mut upload_errors = upload_errors;
let mut seen_error_ids = seen_error_ids;
let uploads_in_flight = uploads_in_flight;
let upload_errors = upload_errors;
move |ev: crate::tiptap_bridge::UploadEventJs| {
consume_upload_event(&ev, &mut uploads_in_flight, &mut upload_errors, &mut seen_error_ids);
consume_upload_event(&ev, uploads_in_flight, upload_errors);
}
});
@ -240,13 +233,11 @@ fn write_editor(post_id: Option<i32>) -> Element {
if title().trim().is_empty() {
error.set(Some("标题不能为空".to_string()));
#[allow(clippy::needless_return)]
return;
}
// 仅在 WASM 前端读取编辑器内容并发起保存请求。
#[cfg(target_arch = "wasm32")]
{
if !title().trim().is_empty() {
// 通过 EditorHandle 实例读取 Markdown句柄未就绪时退回 content signal。
let md = if let Some(handle) = &*editor.read() {
handle.instance().get_markdown()
@ -268,8 +259,9 @@ fn write_editor(post_id: Option<i32>) -> Element {
}
// 将逗号分隔的标签字符串转换为列表。
// 同时支持半角/全角逗号与分号,避免中文输入法下的全角标点被误并入单个标签。
let tags_list: Vec<String> = tags()
.split(',')
.split(|c| matches!(c, ',' | '' | ';' | ''))
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
@ -312,7 +304,6 @@ fn write_editor(post_id: Option<i32>) -> Element {
{
Ok(CreatePostResponse { success: true, .. }) => {
saving.set(false);
success.set(true);
let _ = dioxus::router::navigator().push(Route::Posts {});
}
Ok(CreatePostResponse {
@ -345,8 +336,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
{
Ok(CreatePostResponse { success: true, .. }) => {
saving.set(false);
success.set(true);
let _ = dioxus::router::navigator().push(Route::Admin {});
let _ = dioxus::router::navigator().push(Route::Posts {});
}
Ok(CreatePostResponse {
success: false,
@ -364,6 +354,12 @@ fn write_editor(post_id: Option<i32>) -> Element {
});
}
}
// 非 WASMSSR hydration 前等场景):明确提示无法保存,而非静默无反应。
#[cfg(not(target_arch = "wasm32"))]
if !title().trim().is_empty() {
error.set(Some("此页面需要 JavaScript 才能保存".to_string()));
}
};
let save_button_text = if saving() {
@ -374,6 +370,12 @@ fn write_editor(post_id: Option<i32>) -> Element {
"保存"
};
// 元信息表单复用的样式label 与 input 各一份,避免三处重复粘贴。
const META_LABEL_CLASS: &str =
"block text-[11px] font-medium text-[var(--color-paper-secondary)] tracking-wider mb-2";
const META_INPUT_CLASS: &str =
"w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5";
rsx! {
div { class: "relative flex flex-col flex-1 min-h-0 overflow-hidden",
if loading() {
@ -406,33 +408,33 @@ fn write_editor(post_id: Option<i32>) -> Element {
// 元数据行 - 紧凑精致
div { class: "flex flex-wrap items-end gap-x-8 gap-y-4 text-sm",
div { class: "flex-1 min-w-[140px]",
label { class: "block text-[11px] font-medium text-[var(--color-paper-secondary)] tracking-wider mb-2",
label { class: "{META_LABEL_CLASS}",
"Slug"
}
input {
class: "w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5",
class: "{META_INPUT_CLASS}",
placeholder: "自动生成",
value: "{slug}",
oninput: move |evt| slug.set(evt.value()),
}
}
div { class: "flex-1 min-w-[140px]",
label { class: "block text-[11px] font-medium text-[var(--color-paper-secondary)] tracking-wider mb-2",
label { class: "{META_LABEL_CLASS}",
"标签"
}
input {
class: "w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5",
class: "{META_INPUT_CLASS}",
placeholder: "逗号分隔",
value: "{tags}",
oninput: move |evt| tags.set(evt.value()),
}
}
div { class: "flex-1 min-w-[140px]",
label { class: "block text-[11px] font-medium text-[var(--color-paper-secondary)] tracking-wider mb-2",
label { class: "{META_LABEL_CLASS}",
"封面图"
}
input {
class: "w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5",
class: "{META_INPUT_CLASS}",
placeholder: "URL可选",
value: "{cover_image}",
oninput: move |evt| cover_image.set(evt.value()),
@ -462,12 +464,6 @@ fn write_editor(post_id: Option<i32>) -> Element {
}
}
if success() {
div { class: "flex-shrink-0 px-4 py-2 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded-xl text-sm border border-green-100 dark:border-green-900/30 mb-2",
"保存成功"
}
}
// 上传失败提示:多条堆叠,×关闭同时删除编辑器内失败占位符(避免孤儿)
for err in upload_errors().clone() {
div { class: "flex-shrink-0 flex items-center justify-between gap-3 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl text-sm border border-red-100 dark:border-red-900/30 mb-2",

View File

@ -63,33 +63,38 @@ fn HomePosts(current_page: i32) -> Element {
for post in posts.iter() {
PostCard { post: post.clone() }
}
// 如果当前页没有任何文章,显示空状态提示。
if posts.is_empty() {
// total == 0 表示站点确实无文章:显示空状态,且不渲染分页。
// 注意total > 0 但 posts 为空(如越界页码 /page/9999也不显示空状态
// 避免误导用户以为站点无内容;此时仅靠下方的分页导航引导回有效页。
if total == 0 {
div { class: "text-center text-paper-secondary py-20",
"暂无文章"
}
}
// 在列表底部渲染分页导航
// 仅在有文章时渲染分页导航,避免越界页码下出现孤立的空分页
// frontend variant 不渲染页码计数unit 不显示(仅满足必填 prop
Pagination {
variant: "frontend",
current_page,
total,
per_page: POSTS_PER_PAGE,
prev_route: if current_page - 1 <= 1 {
Route::Home {}
} else {
Route::HomePage { page: current_page - 1 }
},
next_route: Route::HomePage { page: current_page + 1 },
unit: "",
if total > 0 {
Pagination {
variant: "frontend",
current_page,
total,
per_page: POSTS_PER_PAGE,
prev_route: if current_page - 1 <= 1 {
Route::Home {}
} else {
Route::HomePage { page: current_page - 1 }
},
next_route: Route::HomePage { page: current_page + 1 },
unit: "",
}
}
}
}
Some(Err(e)) => {
// 不透传内部错误细节,统一展示通用文案(与标签页等其它页面一致)。
Some(Err(_)) => {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败: {e}"
"加载失败"
}
}
}

View File

@ -78,9 +78,13 @@ pub fn Search() -> Element {
PostCard { post: post.clone() }
}
}
} else if let Some(Err(e)) = search_res() {
} else if search_res()
.as_ref()
.map(|r| r.is_err())
.unwrap_or(false)
{
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"搜索失败: {e}"
"搜索失败"
}
}
}

View File

@ -29,8 +29,8 @@ pub struct UploadErrorEntry {
#[cfg(target_arch = "wasm32")]
pub mod wasm {
use super::{UploadErrorEntry, UploadsInFlight};
// WritableExt 提供 .write()Writable 提供 .set()。Signal 方法需 trait 在作用域内
use dioxus::prelude::{Readable, Writable, WritableExt};
// WritableExt 提供 .write()Signal 在 Copy 语义下不需要 mut 绑定)
use dioxus::prelude::WritableExt;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
@ -144,12 +144,9 @@ pub mod wasm {
/// 持有编辑器实例 + 其全部 closure统一生命周期。
///
/// drop 时先 destroy JS 实例(释放 ProseMirror 资源),再 drop closure
/// (释放 wasm-bindgen 回调表)。比 `Closure::forget`(永久泄漏)干净——
/// closure 严格随编辑器实例同生共死。
///
/// 字段顺序:`instance` 在前仅表示逻辑主从Drop::drop 里显式调 destroy
/// 之后 closure 字段按声明逆序自动 drop无顺序敏感问题。
/// drop 时先 destroy JS 实例(释放 ProseMirror 资源),随后 closure 字段
/// 按声明逆序自动 drop释放 wasm-bindgen 回调表)。比 `Closure::forget`
/// 永久泄漏干净——closure 严格随编辑器实例同生共死。
pub struct EditorHandle {
instance: EditorInstance,
_on_update: Closure<dyn FnMut(String)>,
@ -184,7 +181,6 @@ pub mod wasm {
impl Drop for EditorHandle {
fn drop(&mut self) {
self.instance.destroy();
// closure 字段随后自动 drop逆序on_upload_event → on_ready → on_image_upload → on_update
}
}
@ -194,35 +190,33 @@ pub mod wasm {
///
/// 逻辑与旧轮询 body 一致:
/// - error新 id 追加提示,已存在 id 原地更新消息(重试后再失败)
/// - success/removed移除对应提示 + 清 seen
/// - success/removed移除对应提示
/// - counts直接从事件读JS 已遍历文档算好)
///
/// 去重以 `upload_errors` Vec 自身为唯一数据源(用 iter().any 判重),
/// 不再额外维护 seen_error_ids避免两份状态需手动同步。
pub fn consume_upload_event(
ev: &UploadEventJs,
uploads_in_flight: &mut dioxus::prelude::Signal<UploadsInFlight>,
upload_errors: &mut dioxus::prelude::Signal<Vec<UploadErrorEntry>>,
seen_error_ids: &mut dioxus::prelude::Signal<std::collections::HashSet<String>>,
mut uploads_in_flight: dioxus::prelude::Signal<UploadsInFlight>,
mut upload_errors: dioxus::prelude::Signal<Vec<UploadErrorEntry>>,
) {
let id = ev.upload_id();
match ev.kind().as_str() {
"error" => {
let msg = ev.error_msg().unwrap_or_else(|| "上传失败".to_string());
if seen_error_ids.write().insert(id.clone()) {
// 新失败:追加提示
upload_errors.write().push(UploadErrorEntry {
// 已存在同 id重试后再失败原地更新消息否则追加。
let mut errors = upload_errors.write();
if let Some(entry) = errors.iter_mut().find(|e| e.id == id) {
entry.message = msg;
} else {
errors.push(UploadErrorEntry {
id: id.clone(),
file_name: ev.file_name(),
message: msg,
});
} else {
// 已存在的 id重试后再失败原地更新消息
let mut errors = upload_errors.write();
if let Some(entry) = errors.iter_mut().find(|e| e.id == id) {
entry.message = msg;
}
}
}
"success" | "removed" => {
seen_error_ids.write().remove(&id);
upload_errors.write().retain(|e| e.id != id);
}
_ => {}
@ -245,27 +239,31 @@ pub mod wasm {
/// - 成功 → resolve(url);失败 → reject(Error(服务端中文 error))
///
/// 返回的 closure 签名 `(File) -> Promise` 对应 JS `onImageUpload`。
/// 构造阶段的错误FormData/Request 等)以 rejected Promise 返回,
/// 而非 panic —— 单张坏文件不应导致整个编辑器崩溃。
pub fn make_upload_closure() -> Closure<dyn Fn(web_sys::File) -> js_sys::Promise> {
Closure::new(move |file: web_sys::File| -> js_sys::Promise {
// 构造 FormData字段名 'image' 与服务端 upload.rs 对齐
let form = web_sys::FormData::new().expect("FormData::new failed");
form.append_with_blob("image", &file)
.expect("FormData.append failed");
// 构造 POST 请求credentials same-origin 携带 session cookie
let mut init = web_sys::RequestInit::new();
init.method("POST");
init.body(Some(&form));
init.credentials(web_sys::RequestCredentials::SameOrigin);
let request = web_sys::Request::new_with_str_and_init("/api/upload", &init)
.expect("Request::new failed");
let window = web_sys::window().expect("no window");
let promise = window.fetch_with_request(&request);
// 把 fetch Promise → Future → 解析响应体 → 再包回 Promise
wasm_bindgen_futures::future_to_promise(async move {
// 构造 FormData字段名 'image' 与服务端 upload.rs 对齐
let form = web_sys::FormData::new()
.map_err(|_| js_sys::Error::new("无法构造上传表单"))?;
form.append_with_blob("image", &file)
.map_err(|_| js_sys::Error::new("无法附加文件"))?;
// 构造 POST 请求credentials same-origin 携带 session cookie
let init = web_sys::RequestInit::new();
init.set_method("POST");
// set_body 接收 &JsValue非 OptionFormData: AsRef<JsValue>。
init.set_body(form.as_ref());
init.set_credentials(web_sys::RequestCredentials::SameOrigin);
let request = web_sys::Request::new_with_str_and_init("/api/upload", &init)
.map_err(|_| js_sys::Error::new("无法构造上传请求"))?;
let window = web_sys::window().expect("no window");
let promise = window.fetch_with_request(&request);
// 把 fetch Promise → Future → 解析响应体 → 再包回 Promise
let resp_val = wasm_bindgen_futures::JsFuture::from(promise).await?;
let resp: web_sys::Response = resp_val.dyn_into()?;
@ -303,6 +301,6 @@ pub mod wasm {
#[cfg(target_arch = "wasm32")]
pub use wasm::{
consume_upload_event, make_upload_closure, EditorHandle, EditorInstance, EditorOptions,
TiptapEditorModule, UploadEventJs, get_module,
consume_upload_event, make_upload_closure, EditorHandle, EditorOptions, UploadEventJs,
get_module,
};