mirror of
https://github.com/LiuYuYang01/ThriveX-Blog.git
synced 2026-09-03 06:24:24 +08:00
refactor(Copyright, FriendCard, FriendHero, MagneticCard, ImageList, OptimizedImage): 组件样式和逻辑优化
- 更新 Copyright 组件,简化作者信息展示并增强版权声明的可读性。 - 在 FriendCard 和 FriendHero 组件中优化样式,提升交互效果和视觉一致性。 - 调整 MagneticCard 组件的鼠标移动效果,增强用户体验。 - 在 ImageList 组件中引入图像尺寸计算逻辑,优化单图和多图展示的布局。 - 更新 OptimizedImage 组件,确保图像源的规范化处理,提升图像加载的稳定性。
This commit is contained in:
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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="项目作者"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user