feat(web): redesign full-screen media viewer UX
All checks were successful
CI / test (push) Successful in 27s

This commit is contained in:
2026-03-08 13:14:18 +03:00
parent 10d4e0386a
commit eda84d4d82
3 changed files with 324 additions and 103 deletions

View File

@@ -19,6 +19,7 @@ import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, Message, UserSea
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { useUiStore } from "../store/uiStore";
import { MediaViewer } from "./MediaViewer";
interface Props {
chatId: number | null;
@@ -745,43 +746,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
</div>
) : null}
{mediaViewer ? (
<div className="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/90 p-2 md:p-4" onClick={() => setMediaViewer(null)}>
<div className="relative flex h-full w-full max-w-5xl items-center justify-center" onClick={(event) => event.stopPropagation()}>
<button className="absolute left-2 top-2 z-10 rounded bg-slate-900/80 px-2 py-1 text-xs" onClick={() => setMediaViewer(null)} type="button">
Close
</button>
{mediaViewer.items.length > 1 ? (
<>
<button
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-2 text-lg"
onClick={() => setMediaViewer((prev) => (prev ? { ...prev, index: prev.index <= 0 ? prev.items.length - 1 : prev.index - 1 } : prev))}
type="button"
>
</button>
<button
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-2 text-lg"
onClick={() => setMediaViewer((prev) => (prev ? { ...prev, index: prev.index >= prev.items.length - 1 ? 0 : prev.index + 1 } : prev))}
type="button"
>
</button>
</>
) : null}
<button
className="absolute right-2 top-2 z-10 rounded bg-slate-900/80 px-2 py-1 text-xs"
onClick={() => jumpToMessage(mediaViewer.items[mediaViewer.index].messageId)}
type="button"
>
Jump
</button>
{mediaViewer.items[mediaViewer.index].type === "image" ? (
<img className="max-h-full max-w-full rounded-xl object-contain" src={mediaViewer.items[mediaViewer.index].url} alt="media" />
) : (
<video className="max-h-full max-w-full rounded-xl" controls src={mediaViewer.items[mediaViewer.index].url} />
)}
</div>
</div>
<MediaViewer
index={mediaViewer.index}
items={mediaViewer.items}
onClose={() => setMediaViewer(null)}
onIndexChange={(nextIndex) => setMediaViewer((prev) => (prev ? { ...prev, index: nextIndex } : prev))}
onJumpToMessage={(messageId) => jumpToMessage(messageId)}
onToast={showToast}
open
/>
) : null}
</div>,
document.body

View File

@@ -0,0 +1,305 @@
import { useEffect, useMemo, useRef, useState, type PointerEvent, type WheelEvent } from "react";
import { createPortal } from "react-dom";
export type MediaViewerItem = {
url: string;
type: "image" | "video";
messageId?: number;
};
interface MediaViewerProps {
open: boolean;
items: MediaViewerItem[];
index: number;
onIndexChange: (index: number) => void;
onClose: () => void;
onToast?: (message: string) => void;
onJumpToMessage?: (messageId: number) => void;
}
export function MediaViewer(props: MediaViewerProps) {
const { open, items, index, onIndexChange, onClose, onToast, onJumpToMessage } = props;
const [zoom, setZoom] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const dragStartRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);
const swipeStartRef = useRef<{ x: number; y: number } | null>(null);
const current = useMemo(() => items[index] ?? null, [items, index]);
const canPrev = items.length > 1;
const canNext = items.length > 1;
useEffect(() => {
setZoom(1);
setOffset({ x: 0, y: 0 });
setDragging(false);
dragStartRef.current = null;
swipeStartRef.current = null;
}, [index, open]);
useEffect(() => {
if (!open) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
return;
}
if (event.key === "ArrowLeft" && canPrev) {
onIndexChange(index <= 0 ? items.length - 1 : index - 1);
return;
}
if (event.key === "ArrowRight" && canNext) {
onIndexChange(index >= items.length - 1 ? 0 : index + 1);
return;
}
if ((event.key === "+" || event.key === "=") && current?.type === "image") {
setZoom((prev) => Math.min(5, prev + 0.25));
return;
}
if (event.key === "-" && current?.type === "image") {
setZoom((prev) => Math.max(1, prev - 0.25));
return;
}
if (event.key === "0" && current?.type === "image") {
setZoom(1);
setOffset({ x: 0, y: 0 });
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [canNext, canPrev, current?.type, index, items.length, onClose, onIndexChange, open]);
if (!open || !current || !items.length) {
return null;
}
async function handleDownload() {
try {
await downloadFileFromUrl(current.url);
onToast?.("File downloaded");
} catch {
onToast?.("Download failed");
}
}
async function handleCopyLink() {
try {
await navigator.clipboard.writeText(current.url);
onToast?.("Link copied");
} catch {
onToast?.("Failed to copy link");
}
}
function handleWheel(event: WheelEvent<HTMLDivElement>) {
if (current.type !== "image") return;
event.preventDefault();
const delta = event.deltaY > 0 ? -0.15 : 0.15;
setZoom((prev) => {
const next = Math.min(5, Math.max(1, prev + delta));
if (next === 1) {
setOffset({ x: 0, y: 0 });
}
return next;
});
}
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
if (current.type !== "image") return;
swipeStartRef.current = { x: event.clientX, y: event.clientY };
if (zoom <= 1) return;
setDragging(true);
dragStartRef.current = { x: event.clientX, y: event.clientY, ox: offset.x, oy: offset.y };
}
function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
if (!dragging || !dragStartRef.current || zoom <= 1) return;
const dx = event.clientX - dragStartRef.current.x;
const dy = event.clientY - dragStartRef.current.y;
setOffset({ x: dragStartRef.current.ox + dx, y: dragStartRef.current.oy + dy });
}
function handlePointerUp(event: PointerEvent<HTMLDivElement>) {
const swipeStart = swipeStartRef.current;
swipeStartRef.current = null;
if (dragging) {
setDragging(false);
dragStartRef.current = null;
return;
}
if (!swipeStart || items.length < 2 || zoom > 1) {
return;
}
const dx = event.clientX - swipeStart.x;
const dy = event.clientY - swipeStart.y;
if (Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy)) {
if (dx < 0) {
onIndexChange(index >= items.length - 1 ? 0 : index + 1);
} else {
onIndexChange(index <= 0 ? items.length - 1 : index - 1);
}
}
}
return createPortal(
<div className="fixed inset-0 z-[180] bg-black/80 backdrop-blur-md" onClick={onClose}>
<div className="absolute inset-0 flex items-center justify-center p-2 md:p-6" onClick={(event) => event.stopPropagation()}>
<div className="relative flex h-full w-full max-w-7xl flex-col overflow-hidden rounded-2xl border border-white/10 bg-slate-950/70 shadow-2xl">
<div className="flex items-center justify-between gap-2 border-b border-white/10 bg-slate-900/80 px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-100">{extractFileName(current.url)}</p>
<p className="text-xs text-slate-400">
{index + 1} / {items.length}
</p>
</div>
<div className="flex items-center gap-1.5">
{current.messageId && onJumpToMessage ? (
<button
className="rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs text-slate-200 transition hover:bg-white/10"
onClick={() => onJumpToMessage(current.messageId!)}
type="button"
>
Jump
</button>
) : null}
<button
className="rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs text-slate-200 transition hover:bg-white/10"
onClick={() => window.open(current.url, "_blank", "noopener,noreferrer")}
type="button"
>
Open
</button>
<button
className="rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs text-slate-200 transition hover:bg-white/10"
onClick={() => void handleCopyLink()}
type="button"
>
Copy link
</button>
<button
className="rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs text-slate-200 transition hover:bg-white/10"
onClick={() => void handleDownload()}
type="button"
>
Download
</button>
<button
className="rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs font-semibold text-slate-100 transition hover:bg-white/10"
onClick={onClose}
type="button"
>
Close
</button>
</div>
</div>
<div
className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black/30"
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={() => {
setDragging(false);
dragStartRef.current = null;
swipeStartRef.current = null;
}}
onDoubleClick={() => {
if (current.type !== "image") return;
if (zoom > 1) {
setZoom(1);
setOffset({ x: 0, y: 0 });
} else {
setZoom(2);
}
}}
>
{canPrev ? (
<button
className="absolute left-2 top-1/2 z-20 -translate-y-1/2 rounded-full border border-white/20 bg-black/45 px-3 py-2 text-xl text-white transition hover:bg-black/70"
onClick={() => onIndexChange(index <= 0 ? items.length - 1 : index - 1)}
type="button"
>
</button>
) : null}
{canNext ? (
<button
className="absolute right-2 top-1/2 z-20 -translate-y-1/2 rounded-full border border-white/20 bg-black/45 px-3 py-2 text-xl text-white transition hover:bg-black/70"
onClick={() => onIndexChange(index >= items.length - 1 ? 0 : index + 1)}
type="button"
>
</button>
) : null}
{current.type === "image" ? (
<img
alt="media"
className="max-h-full max-w-full select-none object-contain transition-transform duration-150"
draggable={false}
src={current.url}
style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`, cursor: zoom > 1 ? "grab" : "zoom-in" }}
/>
) : (
<video className="max-h-full max-w-full rounded-xl" controls src={current.url} />
)}
</div>
{items.length > 1 ? (
<div className="border-t border-white/10 bg-slate-900/80 px-2 py-2">
<div className="flex gap-2 overflow-x-auto">
{items.map((item, itemIndex) => (
<button
className={`relative h-14 w-20 shrink-0 overflow-hidden rounded-md border ${
itemIndex === index ? "border-sky-400" : "border-white/10"
}`}
key={`${item.url}-${itemIndex}`}
onClick={() => onIndexChange(itemIndex)}
type="button"
>
{item.type === "image" ? (
<img alt="" className="h-full w-full object-cover" draggable={false} src={item.url} />
) : (
<div className="flex h-full w-full items-center justify-center bg-slate-800 text-xs text-slate-200">VIDEO</div>
)}
{itemIndex === index ? <span className="absolute inset-0 ring-1 ring-sky-300/70" /> : null}
</button>
))}
</div>
</div>
) : null}
</div>
</div>
</div>,
document.body
);
}
function extractFileName(url: string): string {
try {
const parsed = new URL(url);
const value = parsed.pathname.split("/").pop();
return decodeURIComponent(value || "file");
} catch {
const value = url.split("/").pop();
return value ? decodeURIComponent(value) : "file";
}
}
async function downloadFileFromUrl(url: string): Promise<void> {
const response = await fetch(url, { mode: "cors" });
if (!response.ok) {
throw new Error("Download failed");
}
const blob = await response.blob();
const filename = extractFileName(url);
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(blobUrl);
}

View File

@@ -15,6 +15,7 @@ import { useAudioPlayerStore } from "../store/audioPlayerStore";
import { useUiStore } from "../store/uiStore";
import { formatTime } from "../utils/format";
import { formatMessageHtml } from "../utils/formatMessage";
import { MediaViewer } from "./MediaViewer";
type ContextMenuState = {
x: number;
@@ -724,72 +725,14 @@ export function MessageList() {
) : null}
{mediaViewer ? (
<div className="fixed inset-0 z-[170] flex items-center justify-center bg-slate-950/90 p-2 md:p-4" onClick={() => setMediaViewer(null)}>
<div className="relative flex h-full w-full max-w-5xl items-center justify-center" onClick={(event) => event.stopPropagation()}>
<button
className="absolute left-1 top-1 z-10 rounded-full bg-slate-900/80 px-3 py-2 text-xs text-slate-200 hover:bg-slate-800 md:left-3 md:top-3"
onClick={() => setMediaViewer(null)}
type="button"
>
Close
</button>
{mediaViewer.items.length > 1 ? (
<>
<button
className="absolute left-1/2 top-3 z-10 -translate-x-1/2 rounded-full bg-slate-900/80 px-2 py-1 text-xs text-slate-200 md:top-4"
type="button"
>
{mediaViewer.index + 1} / {mediaViewer.items.length}
</button>
<button
className="absolute left-1 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-3 text-lg text-slate-200 hover:bg-slate-800 md:left-3"
onClick={() =>
setMediaViewer((prev) =>
prev ? { ...prev, index: prev.index <= 0 ? prev.items.length - 1 : prev.index - 1 } : prev
)
}
type="button"
>
</button>
<button
className="absolute right-1 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-3 text-lg text-slate-200 hover:bg-slate-800 md:right-3"
onClick={() =>
setMediaViewer((prev) =>
prev ? { ...prev, index: prev.index >= prev.items.length - 1 ? 0 : prev.index + 1 } : prev
)
}
type="button"
>
</button>
</>
) : null}
<button
className="absolute right-1 top-1 z-10 rounded-full bg-slate-900/80 px-3 py-2 text-xs text-slate-200 hover:bg-slate-800 md:right-3 md:top-3"
onClick={async () => {
const current = mediaViewer.items[mediaViewer.index];
try {
await downloadFileFromUrl(current.url);
showToast("File downloaded");
} catch {
showToast("Download failed");
}
}}
type="button"
>
Download
</button>
{mediaViewer.items[mediaViewer.index]?.type === "image" ? (
<img className="max-h-full max-w-full rounded-xl object-contain" src={mediaViewer.items[mediaViewer.index].url} alt="media" />
) : (
<video className="max-h-full max-w-full rounded-xl" controls src={mediaViewer.items[mediaViewer.index].url} />
)}
</div>
</div>
<MediaViewer
index={mediaViewer.index}
items={mediaViewer.items}
onClose={() => setMediaViewer(null)}
onIndexChange={(nextIndex) => setMediaViewer((prev) => (prev ? { ...prev, index: nextIndex } : prev))}
onToast={showToast}
open
/>
) : null}
{deleteMessageId ? (