mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-02-02 14:16:39 +01:00
New UI experiment: media modal + status detail page
- Extracted out the media modal and media component from Status - Use :has CSS selector to do most of the layout work - Expecting edge case UI bugs
This commit is contained in:
parent
ae37d58826
commit
292186e918
7 changed files with 547 additions and 430 deletions
31
src/app.css
31
src/app.css
|
@ -732,6 +732,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
overscroll-behavior: contain;
|
||||
touch-action: pan-x;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
}
|
||||
.carousel::-webkit-scrollbar {
|
||||
display: none;
|
||||
|
@ -743,7 +744,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100vw;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background-color: var(--average-color-alpha);
|
||||
|
@ -756,7 +757,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
}
|
||||
.carousel > * :is(img, video) {
|
||||
width: auto;
|
||||
max-width: 100vw;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 100vh;
|
||||
max-height: 100dvh;
|
||||
|
@ -824,8 +825,8 @@ button.carousel-dot:is(.active, [disabled].active) {
|
|||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
.carousel-controls {
|
||||
transform: scaleX(200%);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
transform: scale(0);
|
||||
/* transition: transform 0.2s ease-in-out; */
|
||||
}
|
||||
:is(.carousel-top-controls, .carousel-controls)[hidden] {
|
||||
opacity: 1;
|
||||
|
@ -846,6 +847,28 @@ button.carousel-dot:is(.active, [disabled].active) {
|
|||
}
|
||||
}
|
||||
|
||||
/* CAROUSEL + STATUS PAGE COMBO */
|
||||
|
||||
@media (min-width: calc(40em + 350px)) {
|
||||
#modal-container > div,
|
||||
.status-deck {
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
/* Don't do this if there's a modal sheet (.sheet) */
|
||||
:has(#modal-container .carousel):has(.status-deck):not(:has(.sheet))
|
||||
.status-deck {
|
||||
width: 350px;
|
||||
min-width: 0;
|
||||
}
|
||||
:has(#modal-container .carousel):has(.status-deck):not(:has(.sheet))
|
||||
#modal-container
|
||||
> div {
|
||||
left: 0;
|
||||
right: 350px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* COMPOSE BUTTON */
|
||||
|
||||
#compose-button {
|
||||
|
|
22
src/app.jsx
22
src/app.jsx
|
@ -20,6 +20,7 @@ import Drafts from './components/drafts';
|
|||
import Icon from './components/icon';
|
||||
import Link from './components/link';
|
||||
import Loader from './components/loader';
|
||||
import MediaModal from './components/media-modal';
|
||||
import Modal from './components/modal';
|
||||
import NotFound from './pages/404';
|
||||
import Bookmarks from './pages/bookmarks';
|
||||
|
@ -312,6 +313,27 @@ function App() {
|
|||
<Drafts />
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showMediaModal && (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
if (
|
||||
e.target === e.currentTarget ||
|
||||
e.target.classList.contains('media')
|
||||
) {
|
||||
states.showMediaModal = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MediaModal
|
||||
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
|
||||
index={snapStates.showMediaModal.index}
|
||||
statusID={snapStates.showMediaModal.statusID}
|
||||
onClose={() => {
|
||||
states.showMediaModal = false;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
282
src/components/media-modal.jsx
Normal file
282
src/components/media-modal.jsx
Normal file
|
@ -0,0 +1,282 @@
|
|||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useMatch } from 'react-router-dom';
|
||||
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import Media from './media';
|
||||
import Modal from './modal';
|
||||
|
||||
function MediaModal({
|
||||
mediaAttachments,
|
||||
statusID,
|
||||
index = 0,
|
||||
onClose = () => {},
|
||||
}) {
|
||||
const carouselRef = useRef(null);
|
||||
const isStatusLocation = useMatch('/s/:id');
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(index);
|
||||
const carouselFocusItem = useRef(null);
|
||||
useLayoutEffect(() => {
|
||||
carouselFocusItem.current?.scrollIntoView();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const scrollLeft = index * carouselRef.current.clientWidth;
|
||||
carouselRef.current.scrollTo({
|
||||
left: scrollLeft,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [index]);
|
||||
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let handleSwipe = () => {
|
||||
onClose();
|
||||
};
|
||||
if (carouselRef.current) {
|
||||
carouselRef.current.addEventListener('swiped-down', handleSwipe);
|
||||
}
|
||||
return () => {
|
||||
if (carouselRef.current) {
|
||||
carouselRef.current.removeEventListener('swiped-down', handleSwipe);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useHotkeys('esc', onClose, [onClose]);
|
||||
|
||||
const [showMediaAlt, setShowMediaAlt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let handleScroll = () => {
|
||||
const { clientWidth, scrollLeft } = carouselRef.current;
|
||||
const index = Math.round(scrollLeft / clientWidth);
|
||||
setCurrentIndex(index);
|
||||
};
|
||||
if (carouselRef.current) {
|
||||
carouselRef.current.addEventListener('scroll', handleScroll, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
if (carouselRef.current) {
|
||||
carouselRef.current.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
tabIndex="-1"
|
||||
data-swipe-threshold="44"
|
||||
class="carousel"
|
||||
onClick={(e) => {
|
||||
if (
|
||||
e.target.classList.contains('carousel-item') ||
|
||||
e.target.classList.contains('media')
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{mediaAttachments?.map((media, i) => {
|
||||
const { blurhash } = media;
|
||||
const rgbAverageColor = blurhash
|
||||
? getBlurHashAverageColor(blurhash)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
class="carousel-item"
|
||||
style={{
|
||||
'--average-color': `rgb(${rgbAverageColor?.join(',')})`,
|
||||
'--average-color-alpha': `rgba(${rgbAverageColor?.join(
|
||||
',',
|
||||
)}, .5)`,
|
||||
}}
|
||||
tabindex="0"
|
||||
key={media.id}
|
||||
ref={i === currentIndex ? carouselFocusItem : null}
|
||||
onClick={(e) => {
|
||||
if (e.target !== e.currentTarget) {
|
||||
setShowControls(!showControls);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!!media.description && (
|
||||
<button
|
||||
type="button"
|
||||
class="plain2 media-alt"
|
||||
hidden={!showControls}
|
||||
onClick={() => {
|
||||
setShowMediaAlt(media.description);
|
||||
}}
|
||||
>
|
||||
<span class="tag">ALT</span>{' '}
|
||||
<span class="media-alt-desc">{media.description}</span>
|
||||
</button>
|
||||
)}
|
||||
<Media media={media} showOriginal />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div class="carousel-top-controls" hidden={!showControls}>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-button plain3"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
</span>
|
||||
{mediaAttachments?.length > 1 ? (
|
||||
<span class="carousel-dots">
|
||||
{mediaAttachments?.map((media, i) => (
|
||||
<button
|
||||
key={media.id}
|
||||
type="button"
|
||||
disabled={i === currentIndex}
|
||||
class={`plain carousel-dot ${
|
||||
i === currentIndex ? 'active' : ''
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
carouselRef.current.scrollTo({
|
||||
left: carouselRef.current.clientWidth * i,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
•
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span>
|
||||
{!isStatusLocation && (
|
||||
<Link
|
||||
to={`/s/${statusID}`}
|
||||
class="button carousel-button plain3"
|
||||
onClick={() => {
|
||||
// if small screen (not media query min-width 40em + 350px), run onClose
|
||||
if (
|
||||
!window.matchMedia('(min-width: calc(40em + 350px))').matches
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
See post »
|
||||
</Link>
|
||||
)}{' '}
|
||||
<a
|
||||
href={
|
||||
mediaAttachments[currentIndex]?.remoteUrl ||
|
||||
mediaAttachments[currentIndex]?.url
|
||||
}
|
||||
target="_blank"
|
||||
class="button carousel-button plain3"
|
||||
title="Open original media in new window"
|
||||
>
|
||||
<Icon icon="popout" alt="Open original media in new window" />
|
||||
</a>{' '}
|
||||
</span>
|
||||
</div>
|
||||
{mediaAttachments?.length > 1 && (
|
||||
<div class="carousel-controls" hidden={!showControls}>
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-button plain3"
|
||||
hidden={currentIndex === 0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
carouselRef.current.scrollTo({
|
||||
left: carouselRef.current.clientWidth * (currentIndex - 1),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="arrow-left" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-button plain3"
|
||||
hidden={currentIndex === mediaAttachments.length - 1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
carouselRef.current.scrollTo({
|
||||
left: carouselRef.current.clientWidth * (currentIndex + 1),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!!showMediaAlt && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowMediaAlt(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="sheet">
|
||||
<header>
|
||||
<h2>Media description</h2>
|
||||
</header>
|
||||
<main>
|
||||
<p
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{showMediaAlt}
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
{!!showMediaAlt && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowMediaAlt(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="sheet">
|
||||
<header>
|
||||
<h2>Media description</h2>
|
||||
</header>
|
||||
<main>
|
||||
<p
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{showMediaAlt}
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MediaModal;
|
198
src/components/media.jsx
Normal file
198
src/components/media.jsx
Normal file
|
@ -0,0 +1,198 @@
|
|||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
import { formatDuration } from './status';
|
||||
|
||||
/*
|
||||
Media type
|
||||
===
|
||||
unknown = unsupported or unrecognized file type
|
||||
image = Static image
|
||||
gifv = Looping, soundless animation
|
||||
video = Video clip
|
||||
audio = Audio track
|
||||
*/
|
||||
|
||||
function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
||||
const { blurhash, description, meta, previewUrl, remoteUrl, url, type } =
|
||||
media;
|
||||
const { original, small, focus } = meta || {};
|
||||
|
||||
const width = showOriginal ? original?.width : small?.width;
|
||||
const height = showOriginal ? original?.height : small?.height;
|
||||
const mediaURL = showOriginal ? url : previewUrl;
|
||||
|
||||
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
|
||||
|
||||
const videoRef = useRef();
|
||||
|
||||
let focalBackgroundPosition;
|
||||
if (focus) {
|
||||
// Convert focal point to CSS background position
|
||||
// Formula from jquery-focuspoint
|
||||
// x = -1, y = 1 => 0% 0%
|
||||
// x = 0, y = 0 => 50% 50%
|
||||
// x = 1, y = -1 => 100% 100%
|
||||
const x = ((focus.x + 1) / 2) * 100;
|
||||
const y = ((1 - focus.y) / 2) * 100;
|
||||
focalBackgroundPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`;
|
||||
}
|
||||
|
||||
if (type === 'image' || (type === 'unknown' && previewUrl && url)) {
|
||||
// Note: type: unknown might not have width/height
|
||||
return (
|
||||
<div
|
||||
class={`media media-image`}
|
||||
onClick={onClick}
|
||||
style={
|
||||
showOriginal && {
|
||||
backgroundImage: `url(${previewUrl})`,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
aspectRatio: `${width}/${height}`,
|
||||
width,
|
||||
height,
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={mediaURL}
|
||||
alt={description}
|
||||
width={width}
|
||||
height={height}
|
||||
loading={showOriginal ? 'eager' : 'lazy'}
|
||||
style={
|
||||
!showOriginal && {
|
||||
backgroundColor:
|
||||
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||
backgroundPosition: focalBackgroundPosition || 'center',
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (type === 'gifv' || type === 'video') {
|
||||
const shortDuration = original.duration < 31;
|
||||
const isGIF = type === 'gifv' && shortDuration;
|
||||
// If GIF is too long, treat it as a video
|
||||
const loopable = original.duration < 61;
|
||||
const formattedDuration = formatDuration(original.duration);
|
||||
const hoverAnimate = !showOriginal && !autoAnimate && isGIF;
|
||||
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
|
||||
return (
|
||||
<div
|
||||
class={`media media-${isGIF ? 'gif' : 'video'} ${
|
||||
autoGIFAnimate ? 'media-contain' : ''
|
||||
}`}
|
||||
data-formatted-duration={formattedDuration}
|
||||
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
|
||||
style={{
|
||||
backgroundColor:
|
||||
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
videoRef.current.pause();
|
||||
} catch (e) {}
|
||||
}
|
||||
onClick(e);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
videoRef.current.play();
|
||||
} catch (e) {}
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
videoRef.current.pause();
|
||||
} catch (e) {}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showOriginal || autoGIFAnimate ? (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
<video
|
||||
src="${url}"
|
||||
poster="${previewUrl}"
|
||||
width="${width}"
|
||||
height="${height}"
|
||||
preload="auto"
|
||||
autoplay
|
||||
muted="${isGIF}"
|
||||
${isGIF ? '' : 'controls'}
|
||||
playsinline
|
||||
loop="${loopable}"
|
||||
${
|
||||
isGIF
|
||||
? 'ondblclick="this.paused ? this.play() : this.pause()"'
|
||||
: ''
|
||||
}
|
||||
></video>
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
) : isGIF ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={url}
|
||||
poster={previewUrl}
|
||||
width={width}
|
||||
height={height}
|
||||
preload="auto"
|
||||
// controls
|
||||
playsinline
|
||||
loop
|
||||
muted
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={description}
|
||||
width={width}
|
||||
height={height}
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (type === 'audio') {
|
||||
const formattedDuration = formatDuration(original.duration);
|
||||
return (
|
||||
<div
|
||||
class="media media-audio"
|
||||
data-formatted-duration={formattedDuration}
|
||||
onClick={onClick}
|
||||
>
|
||||
{showOriginal ? (
|
||||
<audio src={remoteUrl || url} preload="none" controls autoplay />
|
||||
) : previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={description}
|
||||
width={width}
|
||||
height={height}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Media;
|
|
@ -30,6 +30,7 @@ import visibilityIconsMap from '../utils/visibility-icons-map';
|
|||
import Avatar from './avatar';
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import Media from './media';
|
||||
import RelativeTime from './relative-time';
|
||||
|
||||
function fetchAccount(id) {
|
||||
|
@ -151,8 +152,6 @@ function Status({
|
|||
}
|
||||
};
|
||||
|
||||
const [showMediaModal, setShowMediaModal] = useState(false);
|
||||
|
||||
if (reblog) {
|
||||
return (
|
||||
<div class="status-reblog" onMouseEnter={debugHover}>
|
||||
|
@ -408,7 +407,11 @@ function Status({
|
|||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowMediaModal(i);
|
||||
states.showMediaModal = {
|
||||
mediaAttachments,
|
||||
index: i,
|
||||
statusID: readOnly ? null : id,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
@ -621,17 +624,6 @@ function Status({
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
{showMediaModal !== false && (
|
||||
<Modal>
|
||||
<Carousel
|
||||
mediaAttachments={mediaAttachments}
|
||||
index={showMediaModal}
|
||||
onClose={() => {
|
||||
setShowMediaModal(false);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!showEdited && (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
|
@ -654,198 +646,6 @@ function Status({
|
|||
);
|
||||
}
|
||||
|
||||
/*
|
||||
Media type
|
||||
===
|
||||
unknown = unsupported or unrecognized file type
|
||||
image = Static image
|
||||
gifv = Looping, soundless animation
|
||||
video = Video clip
|
||||
audio = Audio track
|
||||
*/
|
||||
|
||||
function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
||||
const { blurhash, description, meta, previewUrl, remoteUrl, url, type } =
|
||||
media;
|
||||
const { original, small, focus } = meta || {};
|
||||
|
||||
const width = showOriginal ? original?.width : small?.width;
|
||||
const height = showOriginal ? original?.height : small?.height;
|
||||
const mediaURL = showOriginal ? url : previewUrl;
|
||||
|
||||
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
|
||||
|
||||
const videoRef = useRef();
|
||||
|
||||
let focalBackgroundPosition;
|
||||
if (focus) {
|
||||
// Convert focal point to CSS background position
|
||||
// Formula from jquery-focuspoint
|
||||
// x = -1, y = 1 => 0% 0%
|
||||
// x = 0, y = 0 => 50% 50%
|
||||
// x = 1, y = -1 => 100% 100%
|
||||
const x = ((focus.x + 1) / 2) * 100;
|
||||
const y = ((1 - focus.y) / 2) * 100;
|
||||
focalBackgroundPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`;
|
||||
}
|
||||
|
||||
if (type === 'image' || (type === 'unknown' && previewUrl && url)) {
|
||||
// Note: type: unknown might not have width/height
|
||||
return (
|
||||
<div
|
||||
class={`media media-image`}
|
||||
onClick={onClick}
|
||||
style={
|
||||
showOriginal && {
|
||||
backgroundImage: `url(${previewUrl})`,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
aspectRatio: `${width}/${height}`,
|
||||
width,
|
||||
height,
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={mediaURL}
|
||||
alt={description}
|
||||
width={width}
|
||||
height={height}
|
||||
loading={showOriginal ? 'eager' : 'lazy'}
|
||||
style={
|
||||
!showOriginal && {
|
||||
backgroundColor:
|
||||
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||
backgroundPosition: focalBackgroundPosition || 'center',
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (type === 'gifv' || type === 'video') {
|
||||
const shortDuration = original.duration < 31;
|
||||
const isGIF = type === 'gifv' && shortDuration;
|
||||
// If GIF is too long, treat it as a video
|
||||
const loopable = original.duration < 61;
|
||||
const formattedDuration = formatDuration(original.duration);
|
||||
const hoverAnimate = !showOriginal && !autoAnimate && isGIF;
|
||||
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
|
||||
return (
|
||||
<div
|
||||
class={`media media-${isGIF ? 'gif' : 'video'} ${
|
||||
autoGIFAnimate ? 'media-contain' : ''
|
||||
}`}
|
||||
data-formatted-duration={formattedDuration}
|
||||
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
|
||||
style={{
|
||||
backgroundColor:
|
||||
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
videoRef.current.pause();
|
||||
} catch (e) {}
|
||||
}
|
||||
onClick(e);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
videoRef.current.play();
|
||||
} catch (e) {}
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
videoRef.current.pause();
|
||||
} catch (e) {}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showOriginal || autoGIFAnimate ? (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
<video
|
||||
src="${url}"
|
||||
poster="${previewUrl}"
|
||||
width="${width}"
|
||||
height="${height}"
|
||||
preload="auto"
|
||||
autoplay
|
||||
muted="${isGIF}"
|
||||
${isGIF ? '' : 'controls'}
|
||||
playsinline
|
||||
loop="${loopable}"
|
||||
${
|
||||
isGIF
|
||||
? 'ondblclick="this.paused ? this.play() : this.pause()"'
|
||||
: ''
|
||||
}
|
||||
></video>
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
) : isGIF ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={url}
|
||||
poster={previewUrl}
|
||||
width={width}
|
||||
height={height}
|
||||
preload="auto"
|
||||
// controls
|
||||
playsinline
|
||||
loop
|
||||
muted
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={description}
|
||||
width={width}
|
||||
height={height}
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (type === 'audio') {
|
||||
const formattedDuration = formatDuration(original.duration);
|
||||
return (
|
||||
<div
|
||||
class="media media-audio"
|
||||
data-formatted-duration={formattedDuration}
|
||||
onClick={onClick}
|
||||
>
|
||||
{showOriginal ? (
|
||||
<audio src={remoteUrl || url} preload="none" controls autoplay />
|
||||
) : previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={description}
|
||||
width={width}
|
||||
height={height}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Card({ card }) {
|
||||
const {
|
||||
blurhash,
|
||||
|
@ -1261,224 +1061,7 @@ function StatusButton({
|
|||
);
|
||||
}
|
||||
|
||||
function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
|
||||
const carouselRef = useRef(null);
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(index);
|
||||
const carouselFocusItem = useRef(null);
|
||||
useLayoutEffect(() => {
|
||||
carouselFocusItem.current?.scrollIntoView();
|
||||
}, []);
|
||||
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let handleSwipe = () => {
|
||||
onClose();
|
||||
};
|
||||
if (carouselRef.current) {
|
||||
carouselRef.current.addEventListener('swiped-down', handleSwipe);
|
||||
}
|
||||
return () => {
|
||||
if (carouselRef.current) {
|
||||
carouselRef.current.removeEventListener('swiped-down', handleSwipe);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useHotkeys('esc', onClose, [onClose]);
|
||||
|
||||
const [showMediaAlt, setShowMediaAlt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let handleScroll = () => {
|
||||
const { clientWidth, scrollLeft } = carouselRef.current;
|
||||
const index = Math.round(scrollLeft / clientWidth);
|
||||
setCurrentIndex(index);
|
||||
};
|
||||
if (carouselRef.current) {
|
||||
carouselRef.current.addEventListener('scroll', handleScroll, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
if (carouselRef.current) {
|
||||
carouselRef.current.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
tabIndex="-1"
|
||||
data-swipe-threshold="44"
|
||||
class="carousel"
|
||||
onClick={(e) => {
|
||||
if (
|
||||
e.target.classList.contains('carousel-item') ||
|
||||
e.target.classList.contains('media')
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{mediaAttachments?.map((media, i) => {
|
||||
const { blurhash } = media;
|
||||
const rgbAverageColor = blurhash
|
||||
? getBlurHashAverageColor(blurhash)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
class="carousel-item"
|
||||
style={{
|
||||
'--average-color': `rgb(${rgbAverageColor?.join(',')})`,
|
||||
'--average-color-alpha': `rgba(${rgbAverageColor?.join(
|
||||
',',
|
||||
)}, .5)`,
|
||||
}}
|
||||
tabindex="0"
|
||||
key={media.id}
|
||||
ref={i === currentIndex ? carouselFocusItem : null}
|
||||
onClick={(e) => {
|
||||
if (e.target !== e.currentTarget) {
|
||||
setShowControls(!showControls);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!!media.description && (
|
||||
<button
|
||||
type="button"
|
||||
class="plain2 media-alt"
|
||||
hidden={!showControls}
|
||||
onClick={() => {
|
||||
setShowMediaAlt(media.description);
|
||||
}}
|
||||
>
|
||||
<span class="tag">ALT</span>{' '}
|
||||
<span class="media-alt-desc">{media.description}</span>
|
||||
</button>
|
||||
)}
|
||||
<Media media={media} showOriginal />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div class="carousel-top-controls" hidden={!showControls}>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-button plain3"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
</span>
|
||||
{mediaAttachments?.length > 1 ? (
|
||||
<span class="carousel-dots">
|
||||
{mediaAttachments?.map((media, i) => (
|
||||
<button
|
||||
key={media.id}
|
||||
type="button"
|
||||
disabled={i === currentIndex}
|
||||
class={`plain carousel-dot ${
|
||||
i === currentIndex ? 'active' : ''
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
carouselRef.current.scrollTo({
|
||||
left: carouselRef.current.clientWidth * i,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
•
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span>
|
||||
<a
|
||||
href={
|
||||
mediaAttachments[currentIndex]?.remoteUrl ||
|
||||
mediaAttachments[currentIndex]?.url
|
||||
}
|
||||
target="_blank"
|
||||
class="button carousel-button plain3"
|
||||
title="Open original media in new window"
|
||||
>
|
||||
<Icon icon="popout" alt="Open original media in new window" />
|
||||
</a>{' '}
|
||||
</span>
|
||||
</div>
|
||||
{mediaAttachments?.length > 1 && (
|
||||
<div class="carousel-controls" hidden={!showControls}>
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-button plain3"
|
||||
hidden={currentIndex === 0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
carouselRef.current.scrollTo({
|
||||
left: carouselRef.current.clientWidth * (currentIndex - 1),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="arrow-left" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-button plain3"
|
||||
hidden={currentIndex === mediaAttachments.length - 1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
carouselRef.current.scrollTo({
|
||||
left: carouselRef.current.clientWidth * (currentIndex + 1),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!!showMediaAlt && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowMediaAlt(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="sheet">
|
||||
<header>
|
||||
<h2>Media description</h2>
|
||||
</header>
|
||||
<main>
|
||||
<p
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{showMediaAlt}
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(time) {
|
||||
export function formatDuration(time) {
|
||||
if (!time) return;
|
||||
let hours = Math.floor(time / 3600);
|
||||
let minutes = Math.floor((time % 3600) / 60);
|
||||
|
|
|
@ -316,6 +316,9 @@ function StatusPage() {
|
|||
if (!pathname || pathname.startsWith('/s/')) return '/';
|
||||
return pathname;
|
||||
}, []);
|
||||
const onClose = () => {
|
||||
states.showMediaModal = false;
|
||||
};
|
||||
|
||||
const [limit, setLimit] = useState(LIMIT);
|
||||
const showMore = useMemo(() => {
|
||||
|
@ -338,6 +341,7 @@ function StatusPage() {
|
|||
|
||||
useHotkeys(['esc', 'backspace'], () => {
|
||||
// location.hash = closeLink;
|
||||
onClose();
|
||||
navigate(closeLink);
|
||||
});
|
||||
|
||||
|
@ -413,7 +417,7 @@ function StatusPage() {
|
|||
|
||||
return (
|
||||
<div class="deck-backdrop">
|
||||
<Link to={closeLink}></Link>
|
||||
<Link to={closeLink} onClick={onClose}></Link>
|
||||
<div
|
||||
tabIndex="-1"
|
||||
ref={scrollableRef}
|
||||
|
@ -519,7 +523,11 @@ function StatusPage() {
|
|||
<Icon icon="eye-open" /> <span>Show all sensitive content</span>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<Link class="button plain deck-close" to={closeLink}>
|
||||
<Link
|
||||
class="button plain deck-close"
|
||||
to={closeLink}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon icon="x" size="xl" />
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
@ -26,6 +26,7 @@ const states = proxy({
|
|||
showSettings: false,
|
||||
showAccount: false,
|
||||
showDrafts: false,
|
||||
showMediaModal: false,
|
||||
composeCharacterCount: 0,
|
||||
settings: {
|
||||
boostsCarousel: store.local.get('settings:boostsCarousel')
|
||||
|
|
Loading…
Reference in a new issue