feat(ui): 随机实心鼠标 + 拖动隐藏 + 修复刷新闪博客
- CursorFollower: 空心环→随机实心几何形状(7选1)+随机颜色,每次刷新换 - 照片墙拖动照片(移动后)隐藏自定义鼠标,松手复现 - App: 加 resolving 闸,异步路由解析完成前不渲染目标页, 修复已登录刷新特殊文件夹时先闪 BlogPage 再跳回照片墙 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6335654904
commit
fdc2f8b94b
@ -20,6 +20,12 @@ function App() {
|
|||||||
)
|
)
|
||||||
// 特殊文件夹入口:如果 hash 匹配一个注册文件夹,存文件夹名
|
// 特殊文件夹入口:如果 hash 匹配一个注册文件夹,存文件夹名
|
||||||
const [wantsFolder, setWantsFolder] = useState<string | null>(null)
|
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
|
const signOut = auth.signOut
|
||||||
|
|
||||||
// 监听地址栏 hash:
|
// 监听地址栏 hash:
|
||||||
@ -34,22 +40,24 @@ function App() {
|
|||||||
setWantsPhoto(photo)
|
setWantsPhoto(photo)
|
||||||
setWantsFolder(null)
|
setWantsFolder(null)
|
||||||
setShowLogin(false)
|
setShowLogin(false)
|
||||||
|
// #photo 与无 hash 都能同步定向,不需要异步解析
|
||||||
|
setResolving(!!hash && !photo)
|
||||||
}
|
}
|
||||||
if (photo) return
|
if (photo || !hash) return
|
||||||
|
|
||||||
if (hash) {
|
// 先查是否是已注册的特殊文件夹
|
||||||
// 先查是否是已注册的特殊文件夹
|
const isFolder = await checkFolder(hash)
|
||||||
const isFolder = await checkFolder(hash)
|
if (!alive || hash !== window.location.hash.replace(/^#/, '')) return
|
||||||
if (!alive || hash !== window.location.hash.replace(/^#/, '')) return
|
if (isFolder) {
|
||||||
if (isFolder) {
|
setWantsFolder(hash)
|
||||||
setWantsFolder(hash)
|
setResolving(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 不是文件夹,查 gate
|
// 不是文件夹,查 gate
|
||||||
const ok = await checkGate(hash)
|
const ok = await checkGate(hash)
|
||||||
if (alive && hash === window.location.hash.replace(/^#/, '')) {
|
if (alive && hash === window.location.hash.replace(/^#/, '')) {
|
||||||
setShowLogin(ok)
|
setShowLogin(ok)
|
||||||
}
|
setResolving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
check()
|
check()
|
||||||
@ -79,7 +87,10 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let view
|
let view
|
||||||
if (auth.username) {
|
if (resolving) {
|
||||||
|
// 路由解析中:先留空,等 checkFolder/checkGate 定向后再渲染目标页
|
||||||
|
view = null
|
||||||
|
} else if (auth.username) {
|
||||||
if (wantsPhoto) {
|
if (wantsPhoto) {
|
||||||
view = <PhotoWallPage onExit={goStart} token={auth.token ?? ''} />
|
view = <PhotoWallPage onExit={goStart} token={auth.token ?? ''} />
|
||||||
} else if (wantsFolder) {
|
} else if (wantsFolder) {
|
||||||
|
|||||||
@ -1,13 +1,39 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { motion, useMotionValue } from 'framer-motion'
|
import { motion, useMotionValue } from 'framer-motion'
|
||||||
|
|
||||||
// 跟随鼠标的小圆环(仿 Few & Far)。
|
// 跟随鼠标的实心几何图案(仿 Few & Far)。
|
||||||
|
// 每次刷新随机抽一个形状 + 随机颜色;悬停可交互元素时放大。
|
||||||
// 仅在精确指针设备启用;触摸设备与 reduced-motion 下不挂载。
|
// 仅在精确指针设备启用;触摸设备与 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() {
|
export function CursorFollower() {
|
||||||
// 位置 1:1 跟手,不做弹簧平滑(平滑在掉帧时会显得发涩/拖影)。
|
// 位置 1:1 跟手,不做弹簧平滑(平滑在掉帧时会显得发涩/拖影)。
|
||||||
const x = useMotionValue(-100)
|
const x = useMotionValue(-100)
|
||||||
const y = 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 [enabled, setEnabled] = useState(false)
|
||||||
const [hovering, setHovering] = useState(false)
|
const [hovering, setHovering] = useState(false)
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
@ -45,20 +71,23 @@ export function CursorFollower() {
|
|||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
aria-hidden
|
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 }}
|
style={{ x, y }}
|
||||||
>
|
>
|
||||||
<motion.span
|
<motion.div
|
||||||
className="block rounded-full border border-accent"
|
className="block"
|
||||||
style={{ translateX: '-50%', translateY: '-50%' }}
|
style={{ translateX: '-50%', translateY: '-50%' }}
|
||||||
animate={{
|
animate={{
|
||||||
width: hovering ? 54 : 24,
|
width: hovering ? 40 : 20,
|
||||||
height: hovering ? 54 : 24,
|
height: hovering ? 40 : 20,
|
||||||
opacity: visible ? 1 : 0,
|
opacity: visible ? 0.9 : 0,
|
||||||
backgroundColor: hovering ? 'rgba(201, 154, 91, 0.22)' : 'rgba(201, 154, 91, 0)',
|
|
||||||
}}
|
}}
|
||||||
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
|
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
|
||||||
/>
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="100%" height="100%">
|
||||||
|
{shape(color)}
|
||||||
|
</svg>
|
||||||
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -644,6 +644,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
|||||||
const dx = ev.clientX - startX
|
const dx = ev.clientX - startX
|
||||||
const dy = ev.clientY - startY
|
const dy = ev.clientY - startY
|
||||||
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
|
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
|
||||||
|
if (!moved) document.body.classList.add('pw-hide-cursor')
|
||||||
moved = true
|
moved = true
|
||||||
clearTimeout(longPress)
|
clearTimeout(longPress)
|
||||||
}
|
}
|
||||||
@ -654,6 +655,7 @@ export function PhotoWallPage({ onExit, token, folderName, isAdmin, onForbidden
|
|||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
clearTimeout(longPress)
|
clearTimeout(longPress)
|
||||||
node.classList.remove('pw-dragging')
|
node.classList.remove('pw-dragging')
|
||||||
|
document.body.classList.remove('pw-hide-cursor')
|
||||||
window.removeEventListener('pointermove', move)
|
window.removeEventListener('pointermove', move)
|
||||||
window.removeEventListener('pointerup', up)
|
window.removeEventListener('pointerup', up)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,11 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 照片墙拖动照片时隐藏自定义鼠标(拖拽中由 PhotoWallPage 切换此类) */
|
||||||
|
body.pw-hide-cursor .cursor-follower {
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* 全站文字默认不可选;输入框/文本域/可编辑区例外,加 data-selectable 可单独放开 */
|
/* 全站文字默认不可选;输入框/文本域/可编辑区例外,加 data-selectable 可单独放开 */
|
||||||
body {
|
body {
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user