Files
MediaLore-Web-App/frontend/src/components/MediaViewer/MediaViewer.tsx
2026-05-20 17:14:51 -04:00

216 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<number | null>(null);
const touchStartY = useRef<number | null>(null);
const swipeAxis = useRef<"horizontal" | "vertical" | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const { data: item } = useQuery<MediaItem>({
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 */}
<div
onClick={onClose}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)", zIndex: 100 }}
/>
{/* Prev */}
<button
onClick={() => prevId && onNavigate(prevId)}
disabled={!prevId}
style={{ position: "fixed", left: 16, top: "50%", transform: "translateY(-50%)", zIndex: 102, fontSize: 36, background: "none", border: "none", color: prevId ? "#fff" : "#444", cursor: prevId ? "pointer" : "default" }}
>
</button>
{/* Next */}
<button
onClick={() => nextId && onNavigate(nextId)}
disabled={!nextId}
style={{ position: "fixed", right: showTags ? TAG_PANEL_WIDTH + EDGE_GAP : EDGE_GAP, top: "50%", transform: "translateY(-50%)", zIndex: 102, fontSize: 36, background: "none", border: "none", color: nextId ? "#fff" : "#444", cursor: nextId ? "pointer" : "default" }}
>
</button>
{/* Media card */}
<div
ref={contentRef}
onClick={(e) => 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 && (
<div style={{ color: "#ccc", fontSize: 13 }}>{item.filename}</div>
)}
{item?.media_type === "image" && (
<img
src={api.media.fileUrl(mediaId)}
alt={item.filename}
style={{ maxWidth: "70vw", maxHeight: "82vh", objectFit: "contain" }}
/>
)}
{item?.media_type === "video" && (
<video
src={api.media.fileUrl(mediaId)}
controls
style={{ maxWidth: "70vw", maxHeight: "82vh" }}
/>
)}
</div>
{/* Top-right controls */}
<div style={{ position: "fixed", top: 16, right: 16, zIndex: 103, display: "flex", gap: 8 }}>
<button
onClick={() => setShowTags((v) => !v)}
style={{ background: "none", border: "none", color: "#fff", fontSize: 20, cursor: "pointer" }}
>
</button>
<button
onClick={onClose}
style={{ background: "none", border: "none", color: "#fff", fontSize: 20, cursor: "pointer" }}
>
</button>
</div>
{/* Tag panel */}
{showTags && item && (
<div
onClick={(e) => 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" }}
>
<TagPanel item={item} />
</div>
)}
</>
);
}