Feat: Add LightBox Component
新增 LightBox 组件用于展示媒体,优化了 Tailwind CSS 配置,增加了动画效果,并在响应式设计中调整了字体大小和样式。
This commit is contained in:
parent
52bea590eb
commit
0a9ae15732
|
|
@ -34,6 +34,10 @@ module.exports = {
|
||||||
"plugin:react-hooks/recommended",
|
"plugin:react-hooks/recommended",
|
||||||
"plugin:jsx-a11y/recommended",
|
"plugin:jsx-a11y/recommended",
|
||||||
],
|
],
|
||||||
|
rules: {
|
||||||
|
"jsx-a11y/media-has-caption": "off",
|
||||||
|
"jsx-a11y/click-events-have-key-events": "off",
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
version: "detect",
|
version: "detect",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||||
|
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||||
|
import React, { useState, useRef, useImperativeHandle, forwardRef, Ref } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { clsn } from '~/utils';
|
||||||
|
|
||||||
|
const parseMeta = (metaStr: string) => {
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(metaStr as string);
|
||||||
|
|
||||||
|
if (!meta) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("FNumber" in meta) {
|
||||||
|
const parts = (meta.FNumber as string).split("/"); // 分割字符串
|
||||||
|
const numerator = parseFloat(parts[0]); // 获取分子
|
||||||
|
const denominator = parseFloat(parts[1]); // 获取分母
|
||||||
|
meta.FNumber = numerator / denominator; // 计算结果
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta as Record<string, string | number>;
|
||||||
|
} catch (e) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LightBoxProps {
|
||||||
|
className?: string;
|
||||||
|
list: Array<API.Media.IMediaData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LightBoxInst {
|
||||||
|
open: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LightBox({ className, list }: LightBoxProps, ref: Ref<LightBoxInst>) {
|
||||||
|
const [state, setState] = useState({
|
||||||
|
loading: false,
|
||||||
|
visible: false,
|
||||||
|
current: 0,
|
||||||
|
fadeOut: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectorRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
let lastScrollTime = 0;
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
open: (index: number) => {
|
||||||
|
setState({ ...state, loading: true, visible: true, current: index });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isFirstItem = state.current === 0;
|
||||||
|
const isLastItem = state.current === list.length - 1;
|
||||||
|
|
||||||
|
const onPrev = (ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
if (isFirstItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ ...state, loading: true, current: state.current - 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onNext = (ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
if (isLastItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ ...state, loading: true, current: state.current + 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickThumb = (ev: React.MouseEvent, index: number) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
setState({ ...state, loading: true, current: index });
|
||||||
|
|
||||||
|
selectorRef.current?.children[state.current].scrollIntoView({
|
||||||
|
block: "nearest",
|
||||||
|
inline: "center",
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLoad = () => {
|
||||||
|
setState({ ...state, loading: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
if (state.fadeOut) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ ...state, fadeOut: true });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setState({ ...state, visible: false, fadeOut: false });
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = (ev: React.WheelEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastScrollTime < 500) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScrollTime = now;
|
||||||
|
|
||||||
|
if (ev.deltaY < 0) {
|
||||||
|
onPrev(ev);
|
||||||
|
} else {
|
||||||
|
onNext(ev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!state.visible && !state.fadeOut) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = list[state.current];
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = item.title;
|
||||||
|
const desc = item.content;
|
||||||
|
const src = item.url;
|
||||||
|
const isVideo = src.includes("mp4");
|
||||||
|
const meta = parseMeta(item.meta as string);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={clsn(
|
||||||
|
"fixed inset-0 z-50 flex flex-col bg-orange-50 overflow-auto animate-fade-in",
|
||||||
|
state.fadeOut && "animate-fade-out",
|
||||||
|
state.loading && "loading",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="bg-pink-400 py-5 px-2 h-16 text-center">
|
||||||
|
<h2 className="text-xl/tight text-white">{title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="md:flex md:flex-row flex-1 overflow-auto">
|
||||||
|
<div
|
||||||
|
className="h-[calc(100%-6em)] md:h-auto flex-1 flex flex-col"
|
||||||
|
onWheel={onScroll}
|
||||||
|
>
|
||||||
|
<div className="flex-1 flex relative p-4" role="img" aria-label={title} onClick={onClose}>
|
||||||
|
{isVideo ? (
|
||||||
|
<video
|
||||||
|
src={src}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
onLoadedMetadata={onLoad}
|
||||||
|
className="m-auto max-w-full max-h-full cursor-zoom-out absolute rounded-xl bg-gray-800"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={item.title}
|
||||||
|
onLoad={onLoad}
|
||||||
|
className="m-auto max-w-[calc(100%-2rem)] md:max-h-[calc(100%-2rem)] inset-0 cursor-zoom-out absolute rounded-xl bg-gray-800"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={selectorRef}
|
||||||
|
className="flex gap-4 p-4 overflow-auto bg-black bg-opacity-10"
|
||||||
|
>
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<img
|
||||||
|
key={item.id}
|
||||||
|
src={item.thumb_url}
|
||||||
|
alt={item.title}
|
||||||
|
className={clsn(
|
||||||
|
"w-16 h-16 cursor-pointer border-3 rounded-xl",
|
||||||
|
state.current === index ? "border-pink-400" : "border-transparent"
|
||||||
|
)}
|
||||||
|
onClick={(ev) => onClickThumb(ev, index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:w-96 overflow-y-auto p-4 sm:p-6 bg-white">
|
||||||
|
<h2 className="text-2xl/tight font-bold mb-1">{title}</h2>
|
||||||
|
<p className="opacity-50 mb-6">
|
||||||
|
<small>{item.take_time}</small>
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-wrap leading-relaxed mb-6">{desc}</p>
|
||||||
|
{meta && (
|
||||||
|
<div className="grid gap-4 text-center mb-6 grid-cols-2">
|
||||||
|
<div className="flex gap-2 p-4 rounded-xl bg-cyan-50">
|
||||||
|
<i className="ri ri-camera-fill opacity-60 text-xl"></i>
|
||||||
|
{meta.Model}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 p-4 rounded-xl bg-cyan-50">
|
||||||
|
<i className="ri ri-cpu-fill opacity-60 text-xl"></i>
|
||||||
|
{meta.Make}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 p-4 rounded-xl bg-cyan-50">
|
||||||
|
<i className="ri ri-user-2-fill opacity-60 text-xl"></i>
|
||||||
|
{meta.Artist}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 p-4 rounded-xl bg-cyan-50">
|
||||||
|
<i className="ri ri-focus-3-fill opacity-60 text-xl"></i>
|
||||||
|
{meta.FocalLengthIn35mmFilm} mm
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 p-4 rounded-xl bg-cyan-50">
|
||||||
|
<i className="ri ri-eye-2-fill opacity-60 text-xl"></i>
|
||||||
|
F/{meta.FNumber}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 p-4 rounded-xl bg-cyan-50">
|
||||||
|
<i className="ri ri-timer-fill opacity-60 text-xl"></i>
|
||||||
|
{meta.ExposureTime} s
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 p-4 rounded-xl bg-cyan-50">
|
||||||
|
<i className="ri ri-contrast-fill opacity-60 text-xl"></i>
|
||||||
|
ISO {meta.ISOSpeedRatings}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
© 版权归创作者 {item.author || meta?.Artist || "奇趣保罗"}{" "}
|
||||||
|
所有,未经许可严禁转载和使用。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default forwardRef(LightBox);
|
||||||
|
|
||||||
|
export const useLightBox = () => {
|
||||||
|
const lightBoxRef = useRef<LightBoxInst>(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ref: lightBoxRef,
|
||||||
|
open: (index: number) => {
|
||||||
|
lightBoxRef.current?.open(index);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,9 @@
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@media screen and (max-width: 639px) {
|
||||||
|
:root {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { json, LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
|
||||||
import Pagination from "~/components/common/pagination";
|
import Pagination from "~/components/common/pagination";
|
||||||
import { clsn, siteTitle } from "~/utils";
|
import { clsn, siteTitle } from "~/utils";
|
||||||
import { StarFill } from "~/components/common/icons";
|
import { StarFill } from "~/components/common/icons";
|
||||||
|
import LightBox, { useLightBox } from "~/components/biz/gallery/image-box";
|
||||||
|
|
||||||
import styles from "./styles.module.less";
|
import styles from "./styles.module.less";
|
||||||
|
|
||||||
|
|
@ -43,6 +44,9 @@ export default function Gallery() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { media, category, page } = useLoaderData<typeof loader>();
|
const { media, category, page } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
// const lightBoxInst = useRef();
|
||||||
|
const { ref: lightBoxInst, open } = useLightBox();
|
||||||
|
|
||||||
const onChangePage = (value: number) => {
|
const onChangePage = (value: number) => {
|
||||||
navigate({
|
navigate({
|
||||||
search: `?page=${value}`,
|
search: `?page=${value}`,
|
||||||
|
|
@ -65,11 +69,18 @@ export default function Gallery() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-4 lg:gap-8 grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mb-12">
|
<section className="grid gap-4 lg:gap-8 grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mb-12">
|
||||||
{media.data.map((item) => (
|
{media.data.map((item, index) => (
|
||||||
<div key={item.id} className="relative bg-white rounded-xl overflow-hidden border-4 border-transparent hover:border-pink-400 transition-colors border-b-4 border-b-cyan-200">
|
<div
|
||||||
|
key={item.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={() => {}}
|
||||||
|
className="relative bg-white rounded-xl overflow-hidden border-4 border-transparent hover:border-pink-400 transition-colors border-b-4 border-b-cyan-200 group"
|
||||||
|
onClick={() => open(index)}
|
||||||
|
>
|
||||||
<img className={styles.image} src={item.thumb_url} alt={item.title} loading="lazy" />
|
<img className={styles.image} src={item.thumb_url} alt={item.title} loading="lazy" />
|
||||||
{item.content && (
|
{item.content && (
|
||||||
<div className={clsn("absolute top-0 left-0 right-0 bottom-0 bg-opacity-60 bg-black p-4 sm:p-6 text-white leading-7 transition-opacity duration-300 opacity-0 hover:opacity-100", styles.desc)}>
|
<div className={clsn("absolute top-0 left-0 right-0 bottom-0 bg-opacity-60 bg-black p-4 sm:p-6 text-white leading-7 transition-opacity duration-300 whitespace-pre-wrap opacity-0 group-hover:opacity-100", styles.desc)}>
|
||||||
{item.content}
|
{item.content}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -88,6 +99,8 @@ export default function Gallery() {
|
||||||
<section className="text-center">
|
<section className="text-center">
|
||||||
<Pagination current={Number(page)} size={20} total={media.count} onClick={onChangePage} />
|
<Pagination current={Number(page)} size={20} total={media.count} onClick={onChangePage} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<LightBox ref={lightBoxInst} list={media.data} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,11 +67,11 @@ export default function Note() {
|
||||||
{item.starred && (
|
{item.starred && (
|
||||||
<StarFill className="absolute -top-5 -right-5 w-28 h-28 text-yellow-300 text-opacity-20 -rotate-[23deg]" />
|
<StarFill className="absolute -top-5 -right-5 w-28 h-28 text-yellow-300 text-opacity-20 -rotate-[23deg]" />
|
||||||
)}
|
)}
|
||||||
<h2 className={clsn(cover && "mr-40", "text-pink-400 text-2xl font-bold mb-4")} style={{ viewTransitionName: `note-title-${item.id}` }}>
|
<h2 className={clsn(cover && "md:mr-40", "text-pink-400 text-2xl font-bold mb-4")} style={{ viewTransitionName: `note-title-${item.id}` }}>
|
||||||
{item.title}
|
{item.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className={clsn(cover && "mr-40", "mb-8 relative")}>{item.except}</p>
|
<p className={clsn(cover && "sm:mr-40", "mb-8 relative")}>{item.except}</p>
|
||||||
<div className={clsn(cover && "mr-40", "flex items-end justify-between text-sm")}>
|
<div className={clsn(cover && "mb-40 sm:mb-0 sm:mr-40", "flex items-end justify-between text-sm")}>
|
||||||
<p className="opacity-60">{item.date}</p>
|
<p className="opacity-60">{item.date}</p>
|
||||||
<span className="flex items-center opacity-60">
|
<span className="flex items-center opacity-60">
|
||||||
<ThumbUpFill className="h-4 w-4 mr-1" />
|
<ThumbUpFill className="h-4 w-4 mr-1" />
|
||||||
|
|
@ -80,7 +80,7 @@ export default function Note() {
|
||||||
</div>
|
</div>
|
||||||
{cover && (
|
{cover && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 right-0 bottom-0 w-40 transition-opacity bg-cover opacity-30 group-hover:opacity-80"
|
className="absolute h-40 sm:h-auto sm:w-40 left-0 sm:top-0 sm:left-[unset] right-0 bottom-0 transition-opacity bg-cover opacity-30 group-hover:opacity-80"
|
||||||
style={{ backgroundImage: `url("${cover}")`, clipPath: "polygon(25% 0%, 100% 0%, 100% 100%, 0% 100%)" }}
|
style={{ backgroundImage: `url("${cover}")`, clipPath: "polygon(25% 0%, 100% 0%, 100% 100%, 0% 100%)" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ declare namespace API {
|
||||||
hidden: boolean
|
hidden: boolean
|
||||||
hidden_ref: boolean
|
hidden_ref: boolean
|
||||||
is_sensitive: boolean
|
is_sensitive: boolean
|
||||||
|
author: string
|
||||||
take_time: string
|
take_time: string
|
||||||
modified: number | string // 文件修改时间
|
modified: number | string // 文件修改时间
|
||||||
starred: boolean
|
starred: boolean
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,41 @@
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: [
|
content: ["./index.html", "./app/**/*.{js,ts,jsx,tsx}"],
|
||||||
"./index.html",
|
|
||||||
"./app/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
"mi": "MiSans",
|
mi: "MiSans",
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'spinner-bar': 'spinnerBar 6s linear infinite',
|
"spinner-bar": "spinnerBar 6s linear infinite",
|
||||||
|
"fade-in": "fadeIn .3s both",
|
||||||
|
"fade-out": "fadeOut .3s both",
|
||||||
|
"fade-in-left": "fadeInLeft .3s backwards",
|
||||||
|
"fade-off-right": "fadeOffRight .3s forwards",
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
spinnerBar: {
|
spinnerBar: {
|
||||||
"0%": { width: "0%" },
|
"0%": { width: "0%" },
|
||||||
"100%": { width: "100%" },
|
"100%": { width: "100%" },
|
||||||
}
|
},
|
||||||
}
|
fadeIn: {
|
||||||
|
"0%": { opacity: 0 },
|
||||||
|
"100%": { opacity: 1 },
|
||||||
|
},
|
||||||
|
fadeOut: {
|
||||||
|
"0%": { opacity: 1 },
|
||||||
|
"100%": { opacity: 0 },
|
||||||
|
},
|
||||||
|
fadeInLeft: {
|
||||||
|
"0%": { opacity: 0, transform: "translateX(1.5rem)" },
|
||||||
|
"100%": { opacity: 1, transform: "translateX(0)" },
|
||||||
|
},
|
||||||
|
fadeOffRight: {
|
||||||
|
"0%": { opacity: 1, transform: "translateX(0)" },
|
||||||
|
"100%": { opacity: 0, transform: "translateX(1.5rem)" },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue