Full presentation-layer rebuild of packages/web (no core/API/model changes). - Design system: indigo light+dark token sets, 25 inline SVG icons, CSS foundation (spacing/radius/type/motion scales, per-theme elevation, reset, scrollbars). - Shell: two-pane rail (brand, icon theme switch, live search, rich conversation rows, account footer, group/contacts compose) + auth card. - Chat pane: sticky header with presence/typing, grouped bubbles with date separators, icon read-ticks, hover toolbar, inline edit, reaction pills, auto-growing composer with Enter-to-send, near-bottom-aware autoscroll. - Media renderers: waveform voice player (WebAudio decode + pseudo-waveform fallback), circular video notes (rendered bare), image lightbox with zoom/pan, framed video->lightbox player, file cards. - Restore + modernize notification toast styles. Emoji kept only as reaction content; all chrome is icons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
173 lines
4.7 KiB
TypeScript
173 lines
4.7 KiB
TypeScript
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { ReactElement, ReactNode } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { CloseIcon, DownloadIcon } from '../../../shared/ui';
|
|
|
|
export interface ViewerItem {
|
|
type: 'image' | 'video';
|
|
url: string;
|
|
name: string;
|
|
}
|
|
|
|
interface ViewerContextValue {
|
|
open: (item: ViewerItem) => void;
|
|
}
|
|
|
|
const ViewerContext = createContext<ViewerContextValue | null>(null);
|
|
|
|
export const useMediaViewer = (): ViewerContextValue => {
|
|
const context = useContext(ViewerContext);
|
|
if (context === null) {
|
|
throw new Error('useMediaViewer must be used within a MediaViewerProvider');
|
|
}
|
|
return context;
|
|
};
|
|
|
|
interface Point {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
const MIN_SCALE = 1;
|
|
const MAX_SCALE = 4;
|
|
|
|
const Overlay = ({ item, onClose }: { item: ViewerItem; onClose: () => void }): ReactElement => {
|
|
const [scale, setScale] = useState(1);
|
|
const [offset, setOffset] = useState<Point>({ x: 0, y: 0 });
|
|
const dragging = useRef<{ startX: number; startY: number; baseX: number; baseY: number } | null>(
|
|
null,
|
|
);
|
|
|
|
const reset = useCallback((): void => {
|
|
setScale(1);
|
|
setOffset({ x: 0, y: 0 });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onKey = (event: KeyboardEvent): void => {
|
|
if (event.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => {
|
|
window.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [onClose]);
|
|
|
|
const onWheel = (event: React.WheelEvent): void => {
|
|
if (item.type !== 'image') {
|
|
return;
|
|
}
|
|
const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale - event.deltaY * 0.0015));
|
|
setScale(nextScale);
|
|
if (nextScale === 1) {
|
|
setOffset({ x: 0, y: 0 });
|
|
}
|
|
};
|
|
|
|
const onPointerDown = (event: React.PointerEvent): void => {
|
|
if (scale <= 1) {
|
|
return;
|
|
}
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
dragging.current = {
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
baseX: offset.x,
|
|
baseY: offset.y,
|
|
};
|
|
};
|
|
|
|
const onPointerMove = (event: React.PointerEvent): void => {
|
|
const state = dragging.current;
|
|
if (state === null) {
|
|
return;
|
|
}
|
|
setOffset({
|
|
x: state.baseX + (event.clientX - state.startX),
|
|
y: state.baseY + (event.clientY - state.startY),
|
|
});
|
|
};
|
|
|
|
const onPointerUp = (): void => {
|
|
dragging.current = null;
|
|
};
|
|
|
|
return (
|
|
<div className="viewer" role="dialog" aria-modal="true" onClick={onClose}>
|
|
<div className="viewer-toolbar" onClick={(event) => {
|
|
event.stopPropagation();
|
|
}}>
|
|
<span className="viewer-name">{item.name}</span>
|
|
<div className="viewer-actions">
|
|
<a
|
|
className="icon-btn viewer-btn"
|
|
href={item.url}
|
|
download={item.name}
|
|
aria-label="Download"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
}}
|
|
>
|
|
<DownloadIcon />
|
|
</a>
|
|
<button type="button" className="icon-btn viewer-btn" aria-label="Close" onClick={onClose}>
|
|
<CloseIcon />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="viewer-stage" onClick={(event) => {
|
|
event.stopPropagation();
|
|
}}>
|
|
{item.type === 'image' ? (
|
|
<img
|
|
src={item.url}
|
|
alt={item.name}
|
|
className="viewer-img"
|
|
draggable={false}
|
|
style={{
|
|
transform: `translate(${String(offset.x)}px, ${String(offset.y)}px) scale(${String(scale)})`,
|
|
cursor: scale > 1 ? 'grab' : 'zoom-in',
|
|
}}
|
|
onWheel={onWheel}
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={onPointerUp}
|
|
onDoubleClick={reset}
|
|
onClick={() => {
|
|
if (scale === 1) {
|
|
setScale(2.2);
|
|
} else {
|
|
reset();
|
|
}
|
|
}}
|
|
/>
|
|
) : (
|
|
<video src={item.url} className="viewer-video" controls autoPlay />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const MediaViewerProvider = ({ children }: { children: ReactNode }): ReactElement => {
|
|
const [item, setItem] = useState<ViewerItem | null>(null);
|
|
|
|
const open = useCallback((next: ViewerItem): void => {
|
|
setItem(next);
|
|
}, []);
|
|
const close = useCallback((): void => {
|
|
setItem(null);
|
|
}, []);
|
|
|
|
const value = useMemo<ViewerContextValue>(() => ({ open }), [open]);
|
|
|
|
return (
|
|
<ViewerContext.Provider value={value}>
|
|
{children}
|
|
{item !== null ? createPortal(<Overlay item={item} onClose={close} />, document.body) : null}
|
|
</ViewerContext.Provider>
|
|
);
|
|
};
|