diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 8f2bbcd..2ae8ba4 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -34,6 +34,10 @@ module.exports = { "plugin:react-hooks/recommended", "plugin:jsx-a11y/recommended", ], + rules: { + "jsx-a11y/media-has-caption": "off", + "jsx-a11y/click-events-have-key-events": "off", + }, settings: { react: { version: "detect", diff --git a/app/components/biz/gallery/image-box/index.tsx b/app/components/biz/gallery/image-box/index.tsx new file mode 100644 index 0000000..641b138 --- /dev/null +++ b/app/components/biz/gallery/image-box/index.tsx @@ -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; + } catch (e) { + return undefined; + } +}; + +export interface LightBoxProps { + className?: string; + list: Array; +} + +export interface LightBoxInst { + open: (index: number) => void; +} + +function LightBox({ className, list }: LightBoxProps, ref: Ref) { + const [state, setState] = useState({ + loading: false, + visible: false, + current: 0, + fadeOut: false, + }); + + const selectorRef = useRef(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( +
+
+

{title}

+
+
+
+
+ {isVideo ? ( +
+
+ {list.map((item, index) => ( + {item.title} onClickThumb(ev, index)} + /> + ))} +
+
+
+

{title}

+

+ {item.take_time} +

+

{desc}

+ {meta && ( +
+
+ + {meta.Model} +
+
+ + {meta.Make} +
+
+ + {meta.Artist} +
+
+ + {meta.FocalLengthIn35mmFilm} mm +
+
+ + F/{meta.FNumber} +
+
+ + {meta.ExposureTime} s +
+
+ + ISO {meta.ISOSpeedRatings} +
+
+ )} +

+ © 版权归创作者 {item.author || meta?.Artist || "奇趣保罗"}{" "} + 所有,未经许可严禁转载和使用。 +

+
+
+
, + document.body + ); +} + +export default forwardRef(LightBox); + +export const useLightBox = () => { + const lightBoxRef = useRef(null); + + return { + ref: lightBoxRef, + open: (index: number) => { + lightBoxRef.current?.open(index); + }, + }; +}; diff --git a/app/index.css b/app/index.css index b5c61c9..42e4801 100644 --- a/app/index.css +++ b/app/index.css @@ -1,3 +1,9 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@media screen and (max-width: 639px) { + :root { + font-size: 14px; + } +} diff --git a/app/routes/gallery.$/route.tsx b/app/routes/gallery.$/route.tsx index 6bdddef..d3ac091 100644 --- a/app/routes/gallery.$/route.tsx +++ b/app/routes/gallery.$/route.tsx @@ -3,6 +3,7 @@ import { json, LoaderFunctionArgs, type MetaFunction } from "@remix-run/node"; import Pagination from "~/components/common/pagination"; import { clsn, siteTitle } from "~/utils"; import { StarFill } from "~/components/common/icons"; +import LightBox, { useLightBox } from "~/components/biz/gallery/image-box"; import styles from "./styles.module.less"; @@ -43,6 +44,9 @@ export default function Gallery() { const navigate = useNavigate(); const { media, category, page } = useLoaderData(); + // const lightBoxInst = useRef(); + const { ref: lightBoxInst, open } = useLightBox(); + const onChangePage = (value: number) => { navigate({ search: `?page=${value}`, @@ -65,11 +69,18 @@ export default function Gallery() {
- {media.data.map((item) => ( -
+ {media.data.map((item, index) => ( +
{}} + 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)} + > {item.title} {item.content && ( -
+
{item.content}
)} @@ -88,6 +99,8 @@ export default function Gallery() {
+ + ); } diff --git a/app/routes/note._index.tsx b/app/routes/note._index.tsx index c16fc39..edd5414 100644 --- a/app/routes/note._index.tsx +++ b/app/routes/note._index.tsx @@ -67,11 +67,11 @@ export default function Note() { {item.starred && ( )} -

+

{item.title}

-

{item.except}

-
+

{item.except}

+

{item.date}

@@ -80,7 +80,7 @@ export default function Note() {
{cover && (
)} diff --git a/app/types/api.media.d.ts b/app/types/api.media.d.ts index 1ab430a..4f8a14d 100644 --- a/app/types/api.media.d.ts +++ b/app/types/api.media.d.ts @@ -10,6 +10,7 @@ declare namespace API { hidden: boolean hidden_ref: boolean is_sensitive: boolean + author: string take_time: string modified: number | string // 文件修改时间 starred: boolean diff --git a/tailwind.config.js b/tailwind.config.js index 7360ae8..64157a7 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,25 +1,41 @@ /** @type {import('tailwindcss').Config} */ export default { - content: [ - "./index.html", - "./app/**/*.{js,ts,jsx,tsx}", - ], + content: ["./index.html", "./app/**/*.{js,ts,jsx,tsx}"], theme: { extend: { fontFamily: { - "mi": "MiSans", + mi: "MiSans", }, 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: { spinnerBar: { "0%": { width: "0%" }, "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: [], -} - +};