refactor(Copyright, FriendCard, FriendHero, MagneticCard, ImageList, OptimizedImage): 组件样式和逻辑优化

- 更新 Copyright 组件,简化作者信息展示并增强版权声明的可读性。
- 在 FriendCard 和 FriendHero 组件中优化样式,提升交互效果和视觉一致性。
- 调整 MagneticCard 组件的鼠标移动效果,增强用户体验。
- 在 ImageList 组件中引入图像尺寸计算逻辑,优化单图和多图展示的布局。
- 更新 OptimizedImage 组件,确保图像源的规范化处理,提升图像加载的稳定性。
This commit is contained in:
刘宇阳
2026-07-08 17:12:27 +08:00
parent b1f900d8a0
commit 47d93ad73c
6 changed files with 204 additions and 78 deletions

View File

@@ -1,41 +1,30 @@
'use client';
import { useAppConfig } from '@/components/AppConfigProvider';
import { FiShield, FiUser } from 'react-icons/fi';
import { FiPenTool } from 'react-icons/fi';
const Copyright = () => {
const { author } = useAppConfig();
// 增加一个 fallback防止未加载时出现空隙
const authorName = author?.name || '匿名作者';
return (
<div className="relative w-full p-4 sm:p-5 mt-8 overflow-hidden rounded-xl bg-[#ecf7fe]/60 border border-[#ecf7fe] dark:bg-[#28323f] dark:border-gray-800/60">
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary dark:bg-blue-500/80 rounded-l-xl" />
<FiShield className="absolute right-2.5 top-5 text-7xl text-blue-500/4 dark:text-gray-700 pointer-events-none rotate-12" />
<div className="relative z-10 flex flex-col gap-3.5">
<div className="flex items-center gap-3 text-sm sm:text-base text-gray-700 dark:text-gray-300">
<div className="flex items-center justify-center shrink-0 w-7 h-7 rounded-full bg-gray-50 dark:bg-transparent shadow-xs text-primary dark:text-gray-400">
<FiUser className="text-base" />
</div>
<p>
<span className="font-medium text-gray-900 dark:text-gray-100">{authorName}</span>
</p>
<div className="my-10 w-full select-none">
<div className="relative flex flex-col gap-4 pt-8 border-t border-gray-200 dark:border-white/10">
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-white dark:bg-neutral-950 px-3 text-primary dark:text-blue-400">
<FiPenTool className="text-sm -rotate-45" />
</div>
<div className="flex items-start sm:items-center gap-3 text-sm sm:text-base text-gray-700 dark:text-gray-300">
<div className="flex items-center justify-center shrink-0 w-7 h-7 rounded-full bg-gray-50 dark:bg-transparent shadow-xs mt-0.5 sm:mt-0 text-primary dark:text-gray-400">
<FiShield className="text-base" />
</div>
<p className="leading-relaxed">
<span className="font-medium text-gray-900 dark:text-gray-100">{authorName}</span>
</p>
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-sm sm:text-base text-gray-500 dark:text-gray-400 font-serif italic">
<span className="text-gray-300 dark:text-white/20 text-xs" aria-hidden="true"></span>
<span className="text-pretty text-center leading-relaxed">
</span>
</div>
<p className="text-center text-[10px] sm:text-xs text-gray-400/80 dark:text-gray-600 tracking-wide font-mono">
REPRINT PLEASE INDICATE SOURCE
</p>
</div>
</div>
);
};
export default Copyright;
export default Copyright;

View File

@@ -17,13 +17,13 @@ export default function FriendCard({ item, type, index }: { item: Web; type: str
<Link href={item.url} target="_blank" className="group block h-full">
<MagneticCard className="h-full">
<div
className={`h-full relative overflow-hidden rounded-2xl border group-hover:shadow-xl group-hover:shadow-primary/10 ${
className={`h-full relative overflow-hidden rounded-2xl border transition-[transform,shadow] duration-300 ease-out group-hover:-translate-y-1 group-hover:shadow-xl group-hover:shadow-primary/10 ${
isPinned
? 'bg-primary/5 dark:bg-primary/10 border-primary/20 dark:border-primary/30'
: 'bg-white/80 dark:bg-gray-800/50 border-gray-200/50 dark:border-gray-700/30'
}`}
>
<div className="absolute inset-0 bg-linear-to-r from-primary/0 via-primary/5 to-primary/0 opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="absolute inset-0 bg-linear-to-r from-primary/0 via-primary/5 to-primary/0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ease-out" />
{isPinned && (
<div className="absolute top-3 right-3 px-2.5 py-1 bg-primary text-[10px] font-bold text-white rounded-full shadow-lg shadow-primary/30">
@@ -33,7 +33,7 @@ export default function FriendCard({ item, type, index }: { item: Web; type: str
<div className="relative p-5 flex items-center gap-4">
<div className="relative shrink-0">
<div className="absolute inset-0 rounded-full bg-primary opacity-0 group-hover:opacity-30 blur-md" />
<div className="absolute inset-0 rounded-full bg-primary opacity-0 group-hover:opacity-30 blur-md transition-opacity duration-500 ease-out" />
<OptimizedImage
src={item.image}
alt={item.title}

View File

@@ -140,14 +140,14 @@ export function OwnerCard() {
<ScrollReveal delay={150}>
<Link href="https://liuyuyang.net" target="_blank" className="group block h-full">
<MagneticCard className="h-full">
<div className="h-full relative overflow-hidden rounded-2xl bg-primary/5 dark:bg-primary/10 border border-primary/20 dark:border-primary/30 group-hover:shadow-xl group-hover:shadow-primary/10">
<div className="absolute inset-0 bg-linear-to-r from-primary/0 via-primary/5 to-primary/0 opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="h-full relative overflow-hidden rounded-2xl bg-primary/5 dark:bg-primary/10 border border-primary/20 dark:border-primary/30 transition-[transform,shadow] duration-300 ease-out group-hover:-translate-y-1 group-hover:shadow-xl group-hover:shadow-primary/10">
<div className="absolute inset-0 bg-linear-to-r from-primary/0 via-primary/5 to-primary/0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ease-out" />
<div className="absolute top-3 right-3 px-2.5 py-1 bg-primary text-[10px] font-bold text-white rounded-full shadow-lg shadow-primary/30">
OWNER
</div>
<div className="relative p-5 flex items-center gap-4">
<div className="relative shrink-0">
<div className="absolute inset-0 rounded-full bg-primary opacity-0 group-hover:opacity-30 blur-md" />
<div className="absolute inset-0 rounded-full bg-primary opacity-0 group-hover:opacity-30 blur-md transition-opacity duration-500 ease-out" />
<img
src="https://q1.qlogo.cn/g?b=qq&nk=3311118881&s=640"
alt="项目作者"

View File

@@ -7,6 +7,7 @@ export default function MagneticCard({ children, className = '' }: { children: R
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
if (!cardRef.current) return;
cardRef.current.style.transition = 'none';
const rect = cardRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
@@ -15,23 +16,19 @@ export default function MagneticCard({ children, className = '' }: { children: R
const distance = Math.sqrt(mouseX * mouseX + mouseY * mouseY);
const maxDistance = Math.max(rect.width, rect.height);
if (distance < maxDistance) {
const strength = (1 - distance / maxDistance) * 15;
const strength = (1 - distance / maxDistance) * 12;
cardRef.current.style.transform = `translate(${(mouseX / rect.width) * strength}px, ${(mouseY / rect.height) * strength}px)`;
}
};
const handleMouseLeave = () => {
if (!cardRef.current) return;
cardRef.current.style.transition = 'transform 0.45s cubic-bezier(0.22, 1, 0.36, 1)';
cardRef.current.style.transform = 'translate(0px, 0px)';
};
return (
<div
ref={cardRef}
className={`transition-[scale] duration-300 ease-out ${className}`}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<div ref={cardRef} className={className} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave}>
{children}
</div>
);

View File

@@ -1,13 +1,141 @@
'use client';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import PhotoPreview, { type PhotoItem } from '@/ThriveUI/PhotoPreview';
interface Props {
list: string[];
}
export default ({ list }: Props) => {
const MAX_SHOW = 9;
type GridLayout = {
gridClass: string;
gapClass: string;
};
type ImageSize = { width: number; height: number };
/** 双图 / 三图 / 四图 / 多图 各自独立尺寸 */
function getGridLayout(count: number): GridLayout {
if (count === 2) {
return { gridClass: 'grid-cols-2 w-[272px]', gapClass: 'gap-1.5' };
}
if (count === 3) {
return { gridClass: 'grid-cols-3 w-full max-w-[390px]', gapClass: 'gap-1.5' };
}
if (count === 4) {
return { gridClass: 'grid-cols-2 w-[272px]', gapClass: 'gap-1.5' };
}
return { gridClass: 'grid-cols-3 w-full max-w-[390px]', gapClass: 'gap-1.5' };
}
/** 单图展示尺寸:按原图比例缩放,容器与图片完全一致 */
function calcSingleDisplaySize(naturalW: number, naturalH: number): ImageSize {
const ratio = naturalW / naturalH;
let maxW: number;
let maxH: number;
if (ratio > 1.25) {
maxW = 320;
maxH = 220;
} else if (ratio < 0.8) {
maxW = 220;
maxH = 380;
} else {
maxW = 260;
maxH = 260;
}
let width = naturalW;
let height = naturalH;
if (width > maxW) {
width = maxW;
height = width / ratio;
}
if (height > maxH) {
height = maxH;
width = height * ratio;
}
return { width: Math.round(width), height: Math.round(height) };
}
function useImageSize(src: string) {
const [size, setSize] = useState<ImageSize | null>(null);
useEffect(() => {
setSize(null);
const img = new Image();
img.onload = () => {
if (img.naturalWidth && img.naturalHeight) {
setSize(calcSingleDisplaySize(img.naturalWidth, img.naturalHeight));
}
};
img.src = src;
}, [src]);
return size;
}
function SingleImage({ src, onClick }: { src: string; onClick: () => void }) {
const size = useImageSize(src);
return (
<button
type="button"
onClick={onClick}
style={size ? { width: size.width, height: size.height } : undefined}
className="group relative block shrink-0 cursor-pointer overflow-hidden rounded"
>
<img
src={src}
alt="闪念图片"
className="block h-full w-full object-cover transition-[scale] duration-500 ease-out group-hover:scale-105"
/>
</button>
);
}
function GridImage({
src,
index,
showMore,
moreCount,
onClick,
}: {
src: string;
index: number;
showMore: boolean;
moreCount: number;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="group relative aspect-square w-full cursor-pointer overflow-hidden rounded"
>
<img
src={src}
alt={`闪念图片-${index}`}
className="block h-full w-full object-cover transition-[scale] duration-500 ease-out group-hover:scale-105"
/>
{showMore && (
<>
<span className="absolute inset-0 bg-[rgba(17,22,25,0.35)]" />
<span className="absolute inset-0 z-2 grid place-items-center text-lg font-bold text-white">
+{moreCount}
</span>
</>
)}
</button>
);
}
export default function ImageList({ list }: Props) {
const [open, setOpen] = useState(false);
const [index, setIndex] = useState(0);
@@ -18,41 +146,51 @@ export default ({ list }: Props) => {
if (!list?.length) return null;
const cols = list.length >= 4 ? 4 : list.length;
const gridClass = cols === 3 ? 'grid-cols-3' : cols === 4 ? 'grid-cols-4' : cols === 2 ? 'grid-cols-2' : 'grid-cols-1';
const photoH = cols === 3 ? 'h-[118px]' : 'h-[164px]';
const count = list.length;
const displayList = list.slice(0, MAX_SHOW);
const hasMore = count > MAX_SHOW;
const openPreview = (i: number) => {
setIndex(i);
setOpen(true);
};
const preview = (
<PhotoPreview
open={open}
photos={photos}
index={index}
onClose={() => setOpen(false)}
onIndexChange={setIndex}
/>
);
if (count === 1) {
return (
<>
<SingleImage src={list[0]} onClick={() => openPreview(0)} />
{preview}
</>
);
}
const { gridClass, gapClass } = getGridLayout(count);
return (
<>
<div className={`grid gap-3 ${gridClass}`}>
{list.slice(0, 4).map((src, i) => (
<button
<div className={`inline-grid ${gapClass} ${gridClass}`}>
{displayList.map((src, i) => (
<GridImage
key={i}
type="button"
onClick={() => {
setIndex(i);
setOpen(true);
}}
className={`group relative w-full overflow-hidden rounded-[7px] bg-[#d8dde3] ${photoH} cursor-pointer`}
>
<img src={src} alt={`闪念图片-${i}`} className="h-full w-full object-cover transition-[scale] duration-500 ease-out group-hover:scale-105" />
{i === 3 && list.length > 4 && (
<>
<span className="absolute inset-0 bg-[rgba(17,22,25,0.35)]" />
<span className="absolute inset-0 z-2 grid place-items-center text-[22px] font-bold text-white">+{list.length - 3}</span>
</>
)}
</button>
src={src}
index={i}
showMore={hasMore && i === MAX_SHOW - 1}
moreCount={count - MAX_SHOW}
onClick={() => openPreview(i)}
/>
))}
</div>
<PhotoPreview
open={open}
photos={photos}
index={index}
onClose={() => setOpen(false)}
onIndexChange={setIndex}
/>
{preview}
</>
);
};
}

View File

@@ -35,27 +35,29 @@ export default function OptimizedImage({
style,
onError,
}: OptimizedImageProps) {
if (!src || (typeof src === 'string' && !src.trim())) return null;
const normalizedSrc = typeof src === 'string' ? src.trim() : src;
if (typeof src === 'string' && isNativeOnlySrc(src)) {
if (!normalizedSrc) return null;
if (typeof normalizedSrc === 'string' && isNativeOnlySrc(normalizedSrc)) {
if (fill) {
return (
<img src={src} alt={alt} className={className} style={{ objectFit: 'cover', width: '100%', height: '100%', ...style }} onError={onError} />
<img src={normalizedSrc} alt={alt} className={className} style={{ objectFit: 'cover', width: '100%', height: '100%', ...style }} onError={onError} />
);
}
return (
<img src={src} alt={alt} className={className} width={width} height={height} style={style} onError={onError} />
<img src={normalizedSrc} alt={alt} className={className} width={width} height={height} style={style} onError={onError} />
);
}
if (fill) {
return <Image src={src} alt={alt} fill className={className} priority={priority} sizes={sizes} style={style} onError={onError} />;
return <Image src={normalizedSrc} alt={alt} fill className={className} priority={priority} sizes={sizes} style={style} onError={onError} />;
}
return (
<Image
src={src}
src={normalizedSrc}
alt={alt}
width={width ?? 100}
height={height ?? 100}