diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 711bafc..6de9078 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -4579,28 +4579,57 @@ html.dark .prose-content pre[data-theme] span { inset: 0; z-index: 1200; display: flex; - align-items: center; - justify-content: center; - padding: 1.5rem; + flex-direction: column; background: rgba(0, 0, 0, 0.82); backdrop-filter: blur(2px); } +.md-lightbox-stage { + flex: 1; + min-height: 0; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + touch-action: none; +} +.md-lightbox-stage.is-zoomed { cursor: grab; } +.md-lightbox-stage.is-panning { cursor: grabbing; } .md-lightbox-figure { margin: 0; - max-width: min(96vw, 1200px); - max-height: 90vh; display: flex; - flex-direction: column; align-items: center; - gap: 0.75rem; + justify-content: center; } .md-lightbox-img { - max-width: 100%; - max-height: calc(90vh - 3rem); + max-width: min(92vw, 1200px); + max-height: calc(100vh - 8.5rem); object-fit: contain; border-radius: 8px; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); user-select: none; + transform-origin: center center; + transition: transform 0.15s ease, filter 0.15s ease; +} +.md-lightbox-img.is-odd-rot { + max-width: calc(100vh - 8.5rem); + max-height: min(92vw, 1200px); +} +.md-lightbox-stage.is-panning .md-lightbox-img, +.md-lightbox-img.is-snap { + transition: none; +} +.md-lightbox-footer { + position: absolute; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + max-width: calc(100vw - 2rem); } .md-lightbox-caption { color: rgba(255, 255, 255, 0.85); @@ -4608,10 +4637,74 @@ html.dark .prose-content pre[data-theme] span { text-align: center; max-width: 40rem; line-height: 1.5; + margin: 0; + text-shadow: 0 1px 8px rgba(0, 0, 0, 0.55); +} +.md-lightbox-toolbar { + display: flex; + align-items: center; + gap: 2px; + padding: 5px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.48); + backdrop-filter: blur(10px); + max-width: 100%; + overflow-x: auto; +} +.md-lightbox-tool, +.md-lightbox-zoom-label { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 999px; + background: transparent; + color: #fff; + cursor: pointer; + transition: background 0.15s ease; +} +.md-lightbox-tool { + width: 36px; + height: 36px; + flex-shrink: 0; +} +.md-lightbox-zoom-label { + min-width: 3.25rem; + height: 36px; + padding: 0 0.35rem; + font-size: 12px; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} +.md-lightbox-tool:hover, +.md-lightbox-zoom-label:hover { + background: rgba(255, 255, 255, 0.14); +} +.md-lightbox-tool.is-on { + background: rgba(255, 255, 255, 0.22); +} +.md-lightbox-tool:disabled, +.md-lightbox-zoom-label:disabled { + opacity: 0.35; + cursor: default; + background: transparent; +} +.md-lightbox-tool:focus-visible, +.md-lightbox-zoom-label:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.7); + outline-offset: 1px; +} +.md-lightbox-sep { + width: 1px; + height: 16px; + margin: 0 4px; + background: rgba(255, 255, 255, 0.22); + flex-shrink: 0; } .md-lightbox-close, .md-lightbox-nav { position: absolute; + z-index: 3; display: inline-flex; align-items: center; justify-content: center; @@ -4641,14 +4734,12 @@ html.dark .prose-content pre[data-theme] span { .md-lightbox-prev { left: 1rem; } .md-lightbox-next { right: 1rem; } .md-lightbox-counter { - position: absolute; - bottom: 1.25rem; - left: 50%; - transform: translateX(-50%); color: rgba(255, 255, 255, 0.8); - font-size: 13px; + font-size: 12.5px; font-variant-numeric: tabular-nums; letter-spacing: 0.04em; + padding: 0 0.55rem; + white-space: nowrap; } /* 评论区的 Markdown 紧凑排版:继承 prose-content 全部规则,仅收紧行距与间距 */ diff --git a/frontend/app/u/[id]/ProfileHeroAvatar.tsx b/frontend/app/u/[id]/ProfileHeroAvatar.tsx new file mode 100644 index 0000000..37d930c --- /dev/null +++ b/frontend/app/u/[id]/ProfileHeroAvatar.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState } from "react"; +import Avatar from "@/components/Avatar"; +import ImageLightbox from "@/components/markdown/ImageLightbox"; + +const RING_STYLE = { + width: 80, + height: 80, + lineHeight: 0, + boxShadow: + "0 0 0 4px var(--panel), 0 0 0 6px color-mix(in srgb, var(--accent) 38%, transparent)", +} as const; + +const RING_CLASS = + "inline-flex items-center justify-center shrink-0 rounded-full p-0"; + +/** 用户主页 Hero 头像:有实图时可点击,复用正文图箱预览 */ +export default function ProfileHeroAvatar({ + name, + src, +}: { + name: string; + src?: string; +}) { + const [open, setOpen] = useState(false); + const canPreview = !!src; + const alt = `${name} 的头像`; + + return ( + <> + {canPreview ? ( + + ) : ( + + + + )} + {open && src ? ( + setOpen(false)} + onIndexChange={() => {}} + /> + ) : null} + + ); +} diff --git a/frontend/app/u/[id]/page.tsx b/frontend/app/u/[id]/page.tsx index bff5c9b..877d868 100644 --- a/frontend/app/u/[id]/page.tsx +++ b/frontend/app/u/[id]/page.tsx @@ -11,7 +11,7 @@ import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; import UserProfileContent from "./UserProfileContent"; import ProfileForm from "./ProfileForm"; import PasswordForm from "./PasswordForm"; -import Avatar from "@/components/Avatar"; +import ProfileHeroAvatar from "./ProfileHeroAvatar"; import RoleBadge from "@/components/RoleBadge"; import { roleMeta } from "@/lib/roles"; import StartDMButton from "./StartDMButton"; @@ -181,22 +181,10 @@ export default async function UserPage({ params, searchParams }: UserPageProps) {/* 身份信息:纯文档流,头像 + 右侧文字 baseline 对齐 */}
- - - +

void; }; -/** 全屏图箱:Esc 关闭,多图时左右切换 */ +type View = { + scale: number; + rotation: number; + invert: boolean; + flipX: boolean; + tx: number; + ty: number; +}; + +const IDENTITY: View = { + scale: 1, + rotation: 0, + invert: false, + flipX: false, + tx: 0, + ty: 0, +}; + +const MIN_SCALE = 0.25; +const MAX_SCALE = 8; +const ZOOM_STEP = 1.25; + +function clampScale(n: number) { + return Math.min(MAX_SCALE, Math.max(MIN_SCALE, n)); +} + +function norm360(deg: number) { + return ((deg % 360) + 360) % 360; +} + +function isIdentity(v: View) { + return ( + v.scale === 1 && + norm360(v.rotation) === 0 && + !v.invert && + !v.flipX && + v.tx === 0 && + v.ty === 0 + ); +} + +function ToolBtn({ + label, + pressed, + disabled, + onClick, + children, + className = "", +}: { + label: string; + pressed?: boolean; + disabled?: boolean; + onClick: () => void; + children: ReactNode; + className?: string; +}) { + return ( + + ); +} + +/** 全屏图箱:Esc 关闭,多图时左右切换;支持缩放 / 旋转 / 翻转 / 反色 */ export default function ImageLightbox({ items, index, @@ -27,6 +116,20 @@ export default function ImageLightbox({ const current = items[index]; const hasNav = total > 1; + const [view, setView] = useState(IDENTITY); + const [panning, setPanning] = useState(false); + const [snap, setSnap] = useState(false); + const viewRef = useRef(view); + viewRef.current = view; + const rootRef = useRef(null); + const stageRef = useRef(null); + const dragRef = useRef<{ + x: number; + y: number; + tx: number; + ty: number; + } | null>(null); + const goPrev = useCallback(() => { if (!hasNav) return; onIndexChange((index - 1 + total) % total); @@ -37,25 +140,192 @@ export default function ImageLightbox({ onIndexChange((index + 1) % total); }, [hasNav, index, total, onIndexChange]); + const zoomBy = useCallback((factor: number, origin?: { x: number; y: number }) => { + setView((v) => { + const next = clampScale(v.scale * factor); + if (next === v.scale) return v; + if (!origin) { + const tx = next <= 1 ? 0 : v.tx; + const ty = next <= 1 ? 0 : v.ty; + return { ...v, scale: next, tx, ty }; + } + const tx = origin.x - ((origin.x - v.tx) / v.scale) * next; + const ty = origin.y - ((origin.y - v.ty) / v.scale) * next; + return { + ...v, + scale: next, + tx: next <= 1 ? 0 : tx, + ty: next <= 1 ? 0 : ty, + }; + }); + }, []); + + const resetView = useCallback(() => { + setSnap(true); + setView(IDENTITY); + }, []); + + useEffect(() => { + if (!snap) return; + let inner = 0; + const outer = requestAnimationFrame(() => { + inner = requestAnimationFrame(() => setSnap(false)); + }); + return () => { + cancelAnimationFrame(outer); + cancelAnimationFrame(inner); + }; + }, [snap]); + + useEffect(() => { + setView(IDENTITY); + setPanning(false); + dragRef.current = null; + }, [index, current?.src]); + useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - else if (e.key === "ArrowLeft") goPrev(); - else if (e.key === "ArrowRight") goNext(); + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key === "ArrowLeft") { + goPrev(); + return; + } + if (e.key === "ArrowRight") { + goNext(); + return; + } + if (e.key === "+" || e.key === "=") { + e.preventDefault(); + zoomBy(ZOOM_STEP); + return; + } + if (e.key === "-" || e.key === "_") { + e.preventDefault(); + zoomBy(1 / ZOOM_STEP); + return; + } + if (e.key === "0") { + e.preventDefault(); + resetView(); + return; + } + if (e.key === "r" || e.key === "R") { + e.preventDefault(); + setView((v) => ({ + ...v, + rotation: v.rotation + (e.shiftKey ? -90 : 90), + tx: 0, + ty: 0, + })); + return; + } + if (e.key === "i" || e.key === "I") { + e.preventDefault(); + setView((v) => ({ ...v, invert: !v.invert })); + return; + } + if (e.key === "h" || e.key === "H") { + e.preventDefault(); + setView((v) => ({ ...v, flipX: !v.flipX })); + } }; window.addEventListener("keydown", onKey); - const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; + return () => window.removeEventListener("keydown", onKey); + }, [onClose, goPrev, goNext, zoomBy, resetView]); + + // 锁背景滚动,但补上滚动条宽度,避免开关图箱时页面左右跳 + useEffect(() => { + const html = document.documentElement; + const { body } = document; + const scrollbar = Math.max(0, window.innerWidth - html.clientWidth); + const prevOverflow = body.style.overflow; + const prevPaddingRight = body.style.paddingRight; + body.style.overflow = "hidden"; + if (scrollbar > 0) { + const currentPad = parseFloat(getComputedStyle(body).paddingRight) || 0; + body.style.paddingRight = `${currentPad + scrollbar}px`; + } return () => { - window.removeEventListener("keydown", onKey); - document.body.style.overflow = prev; + body.style.overflow = prevOverflow; + body.style.paddingRight = prevPaddingRight; }; - }, [onClose, goPrev, goNext]); + }, []); + + useEffect(() => { + const el = rootRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + const stage = stageRef.current; + const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; + if (!stage) { + zoomBy(factor); + return; + } + const rect = stage.getBoundingClientRect(); + zoomBy(factor, { + x: e.clientX - rect.left - rect.width / 2, + y: e.clientY - rect.top - rect.height / 2, + }); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, [zoomBy]); + + const onPointerDown = (e: ReactPointerEvent) => { + if (e.button !== 0) return; + if (viewRef.current.scale <= 1.01) return; + e.currentTarget.setPointerCapture(e.pointerId); + dragRef.current = { + x: e.clientX, + y: e.clientY, + tx: viewRef.current.tx, + ty: viewRef.current.ty, + }; + setPanning(true); + }; + + const onPointerMove = (e: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + setView((v) => ({ + ...v, + tx: drag.tx + (e.clientX - drag.x), + ty: drag.ty + (e.clientY - drag.y), + })); + }; + + const endPan = (e: ReactPointerEvent) => { + if (!dragRef.current) return; + dragRef.current = null; + setPanning(false); + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + }; + + const onDoubleClick = () => { + setView((v) => + v.scale > 1.01 + ? { ...v, scale: 1, tx: 0, ty: 0 } + : { ...v, scale: 2, tx: 0, ty: 0 } + ); + }; if (!current || typeof document === "undefined") return null; + const oddRot = Math.abs(view.rotation) % 180 !== 0; + const sx = view.scale * (view.flipX ? -1 : 1); + const zoomed = view.scale > 1.01; + const zoomPct = `${Math.round(view.scale * 100)}%`; + return createPortal(
-
e.stopPropagation()}> - {index + 1} / {total} -
)} -
e.stopPropagation()} +
- {/* eslint-disable-next-line @next/next/no-img-element */} - {current.alt +
e.stopPropagation()}> + {/* eslint-disable-next-line @next/next/no-img-element */} + {current.alt +
+
+ +
e.stopPropagation()}> {current.alt ? ( -
{current.alt}
+

{current.alt}

) : null} -
+
+ zoomBy(1 / ZOOM_STEP)} + > + + + + = MAX_SCALE} + onClick={() => zoomBy(ZOOM_STEP)} + > + + + + + setView((v) => ({ + ...v, + rotation: v.rotation - 90, + tx: 0, + ty: 0, + })) + } + > + + + + setView((v) => ({ + ...v, + rotation: v.rotation + 90, + tx: 0, + ty: 0, + })) + } + > + + + setView((v) => ({ ...v, flipX: !v.flipX }))} + > + + + setView((v) => ({ ...v, invert: !v.invert }))} + > + + + + + + + {hasNav ? ( + <> + + + {index + 1} / {total} + + + ) : null} +
+

, document.body );