feat: add telegram-style resource stream
This commit is contained in:
180
src/components/messageStream/overlays/ImageLightbox.tsx
Normal file
180
src/components/messageStream/overlays/ImageLightbox.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
|
||||
import type { Attachment } from "../../../types/post";
|
||||
|
||||
type LightboxState = {
|
||||
images: Attachment[];
|
||||
index: number;
|
||||
} | null;
|
||||
|
||||
type Ctx = {
|
||||
openLightbox: (images: Attachment[], startIndex?: number) => void;
|
||||
closeLightbox: () => void;
|
||||
};
|
||||
|
||||
const LightboxContext = createContext<Ctx | null>(null);
|
||||
|
||||
export function useLightbox(): Ctx {
|
||||
const ctx = useContext(LightboxContext);
|
||||
if (!ctx)
|
||||
throw new Error("useLightbox must be used inside ImageLightboxProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ImageLightboxProvider({ children }: PropsWithChildren) {
|
||||
const [state, setState] = useState<LightboxState>(null);
|
||||
|
||||
const openLightbox = useCallback((images: Attachment[], startIndex = 0) => {
|
||||
if (!images.length) return;
|
||||
const i = Math.min(Math.max(0, startIndex), images.length - 1);
|
||||
setState({ images, index: i });
|
||||
}, []);
|
||||
|
||||
const closeLightbox = useCallback(() => setState(null), []);
|
||||
|
||||
return (
|
||||
<LightboxContext.Provider value={{ openLightbox, closeLightbox }}>
|
||||
{children}
|
||||
{state ? (
|
||||
<LightboxView
|
||||
images={state.images}
|
||||
startIndex={state.index}
|
||||
onClose={closeLightbox}
|
||||
/>
|
||||
) : null}
|
||||
</LightboxContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function LightboxView({
|
||||
images,
|
||||
startIndex,
|
||||
onClose,
|
||||
}: {
|
||||
images: Attachment[];
|
||||
startIndex: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [index, setIndex] = useState(startIndex);
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
|
||||
const goPrev = useCallback(
|
||||
() => setIndex((i) => (i - 1 + images.length) % images.length),
|
||||
[images.length],
|
||||
);
|
||||
const goNext = useCallback(
|
||||
() => setIndex((i) => (i + 1) % images.length),
|
||||
[images.length],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "ArrowLeft") goPrev();
|
||||
if (e.key === "ArrowRight") goNext();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [goPrev, goNext, onClose]);
|
||||
|
||||
const current = images[index];
|
||||
if (!current) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={current.url}
|
||||
download={current.filename}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-16 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||
aria-label="Download"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</a>
|
||||
|
||||
{images.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goPrev();
|
||||
}}
|
||||
className="absolute left-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 md:left-6"
|
||||
aria-label="Previous"
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goNext();
|
||||
}}
|
||||
className="absolute right-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 md:right-6"
|
||||
aria-label="Next"
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="absolute bottom-6 left-1/2 z-10 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white">
|
||||
{index + 1} / {images.length}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<img
|
||||
src={current.url}
|
||||
alt={current.filename}
|
||||
className="max-h-[92vh] max-w-[92vw] object-contain select-none"
|
||||
draggable={false}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
if (touchStartX.current == null) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||
if (Math.abs(dx) > 40) {
|
||||
if (dx > 0) goPrev();
|
||||
else goNext();
|
||||
}
|
||||
touchStartX.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user