feat(ui): 随机实心鼠标 + 拖动隐藏 + 修复刷新闪博客

- CursorFollower: 空心环→随机实心几何形状(7选1)+随机颜色,每次刷新换
- 照片墙拖动照片(移动后)隐藏自定义鼠标,松手复现
- App: 加 resolving 闸,异步路由解析完成前不渲染目标页,
  修复已登录刷新特殊文件夹时先闪 BlogPage 再跳回照片墙

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cc 2026-06-25 21:09:18 +08:00
parent 6335654904
commit fdc2f8b94b
4 changed files with 71 additions and 24 deletions

View File

@ -20,6 +20,12 @@ function App() {
)
// 特殊文件夹入口:如果 hash 匹配一个注册文件夹,存文件夹名
const [wantsFolder, setWantsFolder] = useState<string | null>(null)
// 路由是否还在异步解析中。非 #photo 的 hash 要等 checkFolder/checkGate 返回才知道
// 去向;解析完成前先不渲染任何目标页,避免已登录时一闪 BlogPage 兜底再跳回。
const [resolving, setResolving] = useState(() => {
const h = window.location.hash.replace(/^#/, '')
return !!h && h !== PHOTO_HASH
})
const signOut = auth.signOut
// 监听地址栏 hash
@ -34,22 +40,24 @@ function App() {
setWantsPhoto(photo)
setWantsFolder(null)
setShowLogin(false)
// #photo 与无 hash 都能同步定向,不需要异步解析
setResolving(!!hash && !photo)
}
if (photo) return
if (photo || !hash) return
if (hash) {
// 先查是否是已注册的特殊文件夹
const isFolder = await checkFolder(hash)
if (!alive || hash !== window.location.hash.replace(/^#/, '')) return
if (isFolder) {
setWantsFolder(hash)
return
}
// 不是文件夹,查 gate
const ok = await checkGate(hash)
if (alive && hash === window.location.hash.replace(/^#/, '')) {
setShowLogin(ok)
}
// 先查是否是已注册的特殊文件夹
const isFolder = await checkFolder(hash)
if (!alive || hash !== window.location.hash.replace(/^#/, '')) return
if (isFolder) {
setWantsFolder(hash)
setResolving(false)
return
}
// 不是文件夹,查 gate
const ok = await checkGate(hash)
if (alive && hash === window.location.hash.replace(/^#/, '')) {
setShowLogin(ok)
setResolving(false)
}
}
check()
@ -79,7 +87,10 @@ function App() {
}
let view
if (auth.username) {
if (resolving) {
// 路由解析中:先留空,等 checkFolder/checkGate 定向后再渲染目标页
view = null
} else if (auth.username) {
if (wantsPhoto) {
view = <PhotoWallPage onExit={goStart} token={auth.token ?? ''} />
} else if (wantsFolder) {

View File

@ -1,13 +1,39 @@
import { useEffect, useState } from 'react'
import { motion, useMotionValue } from 'framer-motion'
// 跟随鼠标的小圆环(仿 Few & Far
// 跟随鼠标的实心几何图案(仿 Few & Far
// 每次刷新随机抽一个形状 + 随机颜色;悬停可交互元素时放大。
// 仅在精确指针设备启用;触摸设备与 reduced-motion 下不挂载。
// 形状库viewBox 0 0 24 24统一实心填充。
const SHAPES: Array<(c: string) => React.ReactNode> = [
// 圆
(c) => <circle cx={12} cy={12} r={11} fill={c} />,
// 圆角方块
(c) => <rect x={1} y={1} width={22} height={22} rx={4} fill={c} />,
// 菱形
(c) => <path d="M12 0 L24 12 L12 24 L0 12 Z" fill={c} />,
// 三角
(c) => <path d="M12 1 L23 22 L1 22 Z" fill={c} />,
// 五角星
(c) => <path d="M12 1 l2.9 6.6 7.2.6 -5.45 4.75 1.65 7.05 -6.3-3.8 -6.3 3.8 1.65-7.05 -5.45-4.75 7.2-.6 Z" fill={c} />,
// 心形
(c) => <path d="M12 21.5 C12 21.5 1.5 14.3 1.5 7.4 C1.5 4.2 4 1.8 7.1 1.8 C9.2 1.8 11 3 12 4.9 C13 3 14.8 1.8 16.9 1.8 C20 1.8 22.5 4.2 22.5 7.4 C22.5 14.3 12 21.5 12 21.5 Z" fill={c} />,
// 六边形
(c) => <path d="M6.5 2.5 L17.5 2.5 L23 12 L17.5 21.5 L6.5 21.5 L1 12 Z" fill={c} />,
]
export function CursorFollower() {
// 位置 1:1 跟手,不做弹簧平滑(平滑在掉帧时会显得发涩/拖影)。
const x = useMotionValue(-100)
const y = useMotionValue(-100)
// 每次挂载(= 每次刷新)固定一个随机形状 + 随机颜色。
const [{ shape, color }] = useState(() => ({
shape: SHAPES[Math.floor(Math.random() * SHAPES.length)],
color: `hsl(${Math.floor(Math.random() * 360)}, 70%, 55%)`,
}))
const [enabled, setEnabled] = useState(false)
const [hovering, setHovering] = useState(false)
const [visible, setVisible] = useState(false)
@ -45,20 +71,23 @@ export function CursorFollower() {
return (
<motion.div
aria-hidden
className="pointer-events-none fixed left-0 top-0 z-[100]"
className="cursor-follower pointer-events-none fixed left-0 top-0 z-[100]"
style={{ x, y }}
>
<motion.span
className="block rounded-full border border-accent"
<motion.div
className="block"
style={{ translateX: '-50%', translateY: '-50%' }}
animate={{
width: hovering ? 54 : 24,
height: hovering ? 54 : 24,
opacity: visible ? 1 : 0,
backgroundColor: hovering ? 'rgba(201, 154, 91, 0.22)' : 'rgba(201, 154, 91, 0)',
width: hovering ? 40 : 20,
height: hovering ? 40 : 20,
opacity: visible ? 0.9 : 0,
}}
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
/>
>
<svg viewBox="0 0 24 24" width="100%" height="100%">
{shape(color)}
</svg>
</motion.div>
</motion.div>
)
}

View File

@ -644,6 +644,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
const dx = ev.clientX - startX
const dy = ev.clientY - startY
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
if (!moved) document.body.classList.add('pw-hide-cursor')
moved = true
clearTimeout(longPress)
}
@ -654,6 +655,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
const cleanup = () => {
clearTimeout(longPress)
node.classList.remove('pw-dragging')
document.body.classList.remove('pw-hide-cursor')
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}

View File

@ -46,6 +46,11 @@ body {
}
}
/* 照片墙拖动照片时隐藏自定义鼠标(拖拽中由 PhotoWallPage 切换此类) */
body.pw-hide-cursor .cursor-follower {
opacity: 0 !important;
}
/* 全站文字默认不可选;输入框/文本域/可编辑区例外,加 data-selectable 可单独放开 */
body {
-webkit-user-select: none;