feat(web): redesign full-screen media viewer UX
All checks were successful
CI / test (push) Successful in 27s
All checks were successful
CI / test (push) Successful in 27s
This commit is contained in:
@@ -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
|
||||
|
||||
305
web/src/components/MediaViewer.tsx
Normal file
305
web/src/components/MediaViewer.tsx
Normal 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);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user