diff --git a/components/carousel.js b/components/carousel.js
new file mode 100644
index 00000000..666c895b
--- /dev/null
+++ b/components/carousel.js
@@ -0,0 +1,129 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
+import classNames from 'classnames'
+import ArrowLeft from '@/svgs/arrow-left-line.svg'
+import ArrowRight from '@/svgs/arrow-right-line.svg'
+import styles from './carousel.module.css'
+import { useShowModal } from './modal'
+import { Dropdown } from 'react-bootstrap'
+
+function useSwiping ({ moveLeft, moveRight }) {
+ const [touchStartX, setTouchStartX] = useState(null)
+
+ const onTouchStart = useCallback((e) => {
+ if (e.touches.length === 1) {
+ setTouchStartX(e.touches[0].clientX)
+ }
+ }, [])
+
+ const onTouchEnd = useCallback((e) => {
+ if (touchStartX !== null) {
+ const touchEndX = e.changedTouches[0].clientX
+ const diff = touchEndX - touchStartX
+ if (diff > 50) {
+ moveLeft()
+ } else if (diff < -50) {
+ moveRight()
+ }
+ setTouchStartX(null)
+ }
+ }, [touchStartX, moveLeft, moveRight])
+
+ useEffect(() => {
+ document.addEventListener('touchstart', onTouchStart)
+ document.addEventListener('touchend', onTouchEnd)
+ return () => {
+ document.removeEventListener('touchstart', onTouchStart)
+ document.removeEventListener('touchend', onTouchEnd)
+ }
+ }, [onTouchStart, onTouchEnd])
+}
+
+function useArrowKeys ({ moveLeft, moveRight }) {
+ const onKeyDown = useCallback((e) => {
+ if (e.key === 'ArrowLeft') {
+ moveLeft()
+ } else if (e.key === 'ArrowRight') {
+ moveRight()
+ }
+ }, [moveLeft, moveRight])
+
+ useEffect(() => {
+ document.addEventListener('keydown', onKeyDown)
+ return () => document.removeEventListener('keydown', onKeyDown)
+ }, [onKeyDown])
+}
+
+export default function Carousel ({ close, mediaArr, src, originalSrc, setOptions }) {
+ const [index, setIndex] = useState(mediaArr.findIndex(([key]) => key === src))
+ const [currentSrc, canGoLeft, canGoRight] = useMemo(() => {
+ return [mediaArr[index][0], index > 0, index < mediaArr.length - 1]
+ }, [mediaArr, index])
+
+ const moveLeft = useCallback(() => {
+ setIndex(i => Math.max(0, i - 1))
+ }, [setIndex])
+
+ const moveRight = useCallback(() => {
+ setIndex(i => Math.min(mediaArr.length - 1, i + 1))
+ }, [setIndex, mediaArr.length])
+
+ useSwiping({ moveLeft, moveRight })
+ useArrowKeys({ moveLeft, moveRight })
+
+ return (
+
+
+
+
{
+ e.stopPropagation()
+ moveLeft()
+ }}
+ >
+
+
+
{
+ e.stopPropagation()
+ moveRight()
+ }}
+ >
+
+
+
+
+ )
+}
+
+const CarouselContext = createContext()
+
+function CarouselOverflow ({ originalSrc, rel }) {
+ return view original
+}
+
+export function CarouselProvider ({ children }) {
+ const media = useRef(new Map())
+ const showModal = useShowModal()
+
+ const showCarousel = useCallback(({ src }) => {
+ showModal((close, setOptions) => {
+ return
+ }, {
+ fullScreen: true,
+ overflow:
+ })
+ }, [showModal, media.current])
+
+ const addMedia = useCallback(({ src, originalSrc, rel }) => {
+ media.current.set(src, { src, originalSrc, rel })
+ }, [media.current])
+
+ const value = useMemo(() => ({ showCarousel, addMedia }), [showCarousel, addMedia])
+ return {children}
+}
+
+export function useCarousel () {
+ return useContext(CarouselContext)
+}
diff --git a/components/carousel.module.css b/components/carousel.module.css
new file mode 100644
index 00000000..505c999a
--- /dev/null
+++ b/components/carousel.module.css
@@ -0,0 +1,63 @@
+div.fullScreenNavContainer {
+ height: 100%;
+ width: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+ flex-direction: row;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+img.fullScreen {
+ cursor: zoom-out !important;
+ max-height: 100%;
+ max-width: 100vw;
+ min-width: 0;
+ min-height: 0;
+ align-self: center;
+ justify-self: center;
+ user-select: none;
+}
+
+.fullScreenContainer {
+ --bs-columns: 1;
+ --bs-rows: 1;
+ display: grid;
+ width: 100%;
+ height: 100%;
+}
+
+div.fullScreenNav:hover > svg {
+ background-color: rgba(0, 0, 0, .5);
+}
+
+div.fullScreenNav {
+ cursor: pointer;
+ pointer-events: auto;
+ width: 72px;
+ height: 72px;
+ display: flex;
+ align-items: center;
+}
+
+div.fullScreenNav.left {
+ justify-content: flex-start;
+}
+
+div.fullScreenNav.right {
+ justify-content: flex-end;
+}
+
+div.fullScreenNav > svg {
+ border-radius: 50%;
+ backdrop-filter: blur(4px);
+ background-color: rgba(0, 0, 0, 0.7);
+ fill: white;
+ max-height: 34px;
+ max-width: 34px;
+ padding: 0.35rem;
+ margin: .75rem;
+}
\ No newline at end of file
diff --git a/components/item-full.js b/components/item-full.js
index 5dab9dc3..a57b8883 100644
--- a/components/item-full.js
+++ b/components/item-full.js
@@ -24,6 +24,7 @@ import { numWithUnits } from '@/lib/format'
import { useQuoteReply } from './use-quote-reply'
import { UNKNOWN_LINK_REL } from '@/lib/constants'
import classNames from 'classnames'
+import { CarouselProvider } from './carousel'
function BioItem ({ item, handleClick }) {
const { me } = useMe()
@@ -156,20 +157,22 @@ export default function ItemFull ({ item, bio, rank, ...props }) {
)
: }
- {item.parentId
- ?
- : (
- {bio
- ?
- : }
-
)}
- {item.comments &&
-
-
-
}
+
+ {item.parentId
+ ?
+ : (
+ {bio
+ ?
+ : }
+
)}
+ {item.comments &&
+
+
+
}
+
>
)
diff --git a/components/media-or-link.js b/components/media-or-link.js
index 76ac52d7..e5ccc918 100644
--- a/components/media-or-link.js
+++ b/components/media-or-link.js
@@ -1,14 +1,14 @@
import styles from './text.module.css'
import { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react'
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP, parseEmbedUrl } from '@/lib/url'
-import { useShowModal } from './modal'
import { useMe } from './me'
-import { Button, Dropdown } from 'react-bootstrap'
+import { Button } from 'react-bootstrap'
import { UNKNOWN_LINK_REL } from '@/lib/constants'
import classNames from 'classnames'
import { TwitterTweetEmbed } from 'react-twitter-embed'
import YouTube from 'react-youtube'
import useDarkMode from './dark-mode'
+import { useCarousel } from './carousel'
function LinkRaw ({ href, children, src, rel }) {
const isRawURL = /^https?:\/\//.test(children?.[0])
@@ -52,27 +52,15 @@ const Media = memo(function Media ({ src, bestResSrc, srcSet, sizes, width, heig
export default function MediaOrLink ({ linkFallback = true, ...props }) {
const media = useMediaHelper(props)
const [error, setError] = useState(false)
- const showModal = useShowModal()
+ const { showCarousel, addMedia } = useCarousel()
- const handleClick = useCallback(() => showModal(close => {
- return (
-
-
-
- )
- }, {
- fullScreen: true,
- overflow: (
-
- open original
- )
- }), [showModal, media.originalSrc, styles, media.bestResSrc])
+ useEffect(() => {
+ if (!media.image) return
+ addMedia({ src: media.bestResSrc, originalSrc: media.originalSrc, rel: props.rel })
+ }, [media.image])
+
+ const handleClick = useCallback(() => showCarousel({ src: media.bestResSrc }),
+ [showCarousel, media.bestResSrc])
const handleError = useCallback((err) => {
console.error('Error loading media', err)
diff --git a/components/modal.js b/components/modal.js
index f785dd89..96aff77a 100644
--- a/components/modal.js
+++ b/components/modal.js
@@ -36,6 +36,14 @@ export default function useModal () {
forceUpdate()
}, [])
+ const setOptions = useCallback(options => {
+ const current = getCurrentContent()
+ if (current) {
+ current.options = { ...current.options, ...options }
+ forceUpdate()
+ }
+ }, [getCurrentContent, forceUpdate])
+
// this is called on every navigation due to below useEffect
const onClose = useCallback(() => {
while (modalStack.current.length) {
@@ -94,7 +102,7 @@ export default function useModal () {
const showModal = useCallback(
(getContent, options) => {
- const ref = { node: getContent(onClose), options }
+ const ref = { node: getContent(onClose, setOptions), options }
if (options?.replaceModal) {
modalStack.current = [ref]
} else {
diff --git a/components/text.js b/components/text.js
index ac5c2851..741b1e1a 100644
--- a/components/text.js
+++ b/components/text.js
@@ -23,6 +23,7 @@ import isEqual from 'lodash/isEqual'
import UserPopover from './user-popover'
import ItemPopover from './item-popover'
import classNames from 'classnames'
+import { CarouselProvider, useCarousel } from './carousel'
// Explicitely defined start/end tags & which CSS class from text.module.css to apply
export const rehypeSuperscript = () => rehypeStyler('', '', styles.superscript)
@@ -148,8 +149,9 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
return url
}
const srcSet = imgproxyUrls?.[url]
+
return
- }, [imgproxyUrls, topLevel, tab])
+ }, [imgproxyUrls, topLevel, tab, outlawed, rel])
const components = useMemo(() => ({
h1: Heading,
@@ -261,16 +263,29 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
const remarkPlugins = useMemo(() => [gfm, mention, sub], [])
const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], [])
+ const carousel = useCarousel()
return (
-
- {children}
-
+ {carousel && tab !== 'preview'
+ ? (
+
+ {children}
+ )
+ : (
+
+
+ {children}
+
+ )}
{overflowing && !show &&