import { useEffect, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { api, type BrowseEntry, type MediaItem } from "../../api/client"; import TagPanel from "../TagPanel/TagPanel"; interface Props { mediaId: number; siblings: BrowseEntry[]; onClose: () => void; onNavigate: (id: number) => void; } export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }: Props) { const TAG_PANEL_WIDTH = 260; const EDGE_GAP = 16; const BASE_CARD_TRANSFORM = "translate(-50%, -50%)"; const [showTags, setShowTags] = useState(() => window.innerWidth >= 768); const touchStartX = useRef(null); const touchStartY = useRef(null); const swipeAxis = useRef<"horizontal" | "vertical" | null>(null); const contentRef = useRef(null); const { data: item } = useQuery({ queryKey: ["media", mediaId], queryFn: () => api.media.get(mediaId), }); const mediaSiblings = siblings.filter((e) => e.media_item_id != null); const currentIndex = mediaSiblings.findIndex((e) => e.media_item_id === mediaId); const prevId = currentIndex > 0 ? mediaSiblings[currentIndex - 1].media_item_id : null; const nextId = currentIndex < mediaSiblings.length - 1 ? mediaSiblings[currentIndex + 1].media_item_id : null; const cardCenterX = showTags ? `calc((100vw - ${TAG_PANEL_WIDTH}px) / 2)` : "50%"; // Clear inline styles when a new item loads so the card appears cleanly useEffect(() => { if (contentRef.current) { contentRef.current.style.transition = ""; contentRef.current.style.transform = BASE_CARD_TRANSFORM; contentRef.current.style.opacity = "1"; } }, [mediaId, BASE_CARD_TRANSFORM]); useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); if (e.key === "ArrowLeft" && prevId) onNavigate(prevId); if (e.key === "ArrowRight" && nextId) onNavigate(nextId); } const onTouchStart = (e: TouchEvent) => { touchStartX.current = e.touches[0].clientX; touchStartY.current = e.touches[0].clientY; swipeAxis.current = null; if (contentRef.current) contentRef.current.style.transition = "none"; }; const onTouchMove = (e: TouchEvent) => { if (touchStartX.current === null || touchStartY.current === null) return; const dx = e.touches[0].clientX - touchStartX.current; const dy = e.touches[0].clientY - touchStartY.current; // Commit to an axis on the first significant movement if (swipeAxis.current === null && (Math.abs(dx) > 8 || Math.abs(dy) > 8)) { swipeAxis.current = Math.abs(dx) >= Math.abs(dy) ? "horizontal" : "vertical"; } // Vertical gestures (tag panel scroll, etc.) pass through untouched if (swipeAxis.current !== "horizontal") return; e.preventDefault(); if (!contentRef.current) return; contentRef.current.style.transform = `translate(calc(-50% + ${dx}px), -50%)`; contentRef.current.style.opacity = String(Math.max(0.4, 1 - Math.abs(dx) / 400)); }; const onTouchEnd = (e: TouchEvent) => { if (touchStartX.current === null) return; const delta = touchStartX.current - e.changedTouches[0].clientX; touchStartX.current = null; touchStartY.current = null; // Non-horizontal gesture: just reset the transition we disabled on touchstart if (swipeAxis.current !== "horizontal") { swipeAxis.current = null; if (contentRef.current) { contentRef.current.style.transition = ""; contentRef.current.style.transform = BASE_CARD_TRANSFORM; contentRef.current.style.opacity = "1"; } return; } swipeAxis.current = null; const targetId = delta > 0 ? nextId : prevId; if (Math.abs(delta) > 80 && targetId) { const el = contentRef.current; if (el) { const slideX = delta > 0 ? -120 : 120; el.style.transition = "opacity 0.2s ease, transform 0.2s ease"; el.style.transform = `translate(calc(-50% + ${slideX}px), -50%)`; el.style.opacity = "0"; setTimeout(() => onNavigate(targetId), 200); } else { onNavigate(targetId); } } else { // Snap back to center const el = contentRef.current; if (el) { el.style.transition = "opacity 0.25s ease, transform 0.25s ease"; el.style.transform = "translate(-50%, -50%)"; el.style.opacity = "1"; setTimeout(() => { if (contentRef.current) { contentRef.current.style.transition = ""; contentRef.current.style.transform = BASE_CARD_TRANSFORM; contentRef.current.style.opacity = "1"; } }, 260); } } }; window.addEventListener("keydown", onKey); window.addEventListener("touchstart", onTouchStart); window.addEventListener("touchmove", onTouchMove, { passive: false }); window.addEventListener("touchend", onTouchEnd); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("touchstart", onTouchStart); window.removeEventListener("touchmove", onTouchMove); window.removeEventListener("touchend", onTouchEnd); }; }, [prevId, nextId, onClose, onNavigate, BASE_CARD_TRANSFORM]); return ( <> {/* Backdrop */}
{/* Prev */} {/* Next */} {/* Media card */}
e.stopPropagation()} style={{ position: "fixed", top: "50%", left: cardCenterX, transform: BASE_CARD_TRANSFORM, zIndex: 101, background: "#1a1a1a", borderRadius: 8, padding: 16, display: "flex", flexDirection: "column", alignItems: "center", gap: 12, maxWidth: "80vw", maxHeight: "90vh", overflow: "auto" }} > {item?.filename && (
{item.filename}
)} {item?.media_type === "image" && ( {item.filename} )} {item?.media_type === "video" && (
{/* Top-right controls */}
{/* Tag panel */} {showTags && item && (
e.stopPropagation()} style={{ position: "fixed", top: 0, right: 0, height: "100%", width: TAG_PANEL_WIDTH, background: "#1a1a1a", borderLeft: "1px solid #333", padding: "48px 16px 16px", zIndex: 101, overflowY: "auto" }} >
)} ); }