Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/client/src/components/photo-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,14 @@ function Photo(props: {photo: api.Photo; album?: api.Album}) {
setPhotoSelected(galleryKey, props.photo.id, !selected.value)
}}
to={props.album ? `/a/$album/p/$photo` : `/p/$photo`}
viewTransition
params={
props.album ? {album: props.album.id, photo: props.photo.id} : {photo: props.photo.id}
}
>
<div
className="relative aspect-square overflow-hidden rounded-md"
style={{
viewTransitionName: `gallery-photo-${props.photo.id}`,
}}
style={{viewTransitionName: `gallery-photo-${props.photo.id}`}}
>
<img
src={props.photo.thumbhash}
Expand Down
226 changes: 201 additions & 25 deletions packages/client/src/components/photo-strip.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,233 @@
import {useSignal} from "@preact/signals-react"
import {useQuery} from "@tanstack/react-query"
import type {ComponentProps} from "react"
import {Link, useNavigate} from "@tanstack/react-router"
import {ArrowLeft, ArrowRight} from "lucide-react"
import {useLayoutEffect, useRef, type ComponentProps, type PointerEvent} from "react"

import {Photo} from "#components/photo"
import {Button} from "#components/ui/button"
import {queryAlbumPhotoOptions, queryPhotoOptions} from "#hooks/queries"
import {cn} from "#lib/utils"

type PhotoProps = ComponentProps<typeof Photo>

function AdjacentPhoto(props: {id: string; viewer: PhotoProps; side: "prev" | "next"}) {
function PhotoSlide(props: {id: string; viewer: PhotoProps; position: number}) {
const photo = useQuery(
props.viewer.album
? queryAlbumPhotoOptions(props.viewer.album.id, props.id)
: queryPhotoOptions(props.id),
)
if (!photo.data) {
const active = props.position === 0
const data = active ? props.viewer.photo : photo.data
if (!data) {
return null
}
return (
<div
className={cn(
"absolute inset-0 flex",
props.side === "prev" ? "-translate-x-full" : "translate-x-full",
props.position === -1 && "-translate-x-full",
props.position === 1 && "translate-x-full",
)}
inert
aria-hidden="true"
inert={!active}
aria-hidden={!active}
>
<Photo {...props.viewer} photo={photo.data} captionEditable={false} preview />
<Photo
{...props.viewer}
photo={data}
captionEditable={active && props.viewer.captionEditable}
preview={!active || props.viewer.preview}
/>
</div>
)
}

export function PhotoStrip(props: PhotoProps) {
const navigate = useNavigate()
const drag = useSignal<{pointerId: number; startX: number} | null>(null)
const offset = useSignal(0)
const settling = useRef(false)
const track = useRef<HTMLDivElement>(null)
const suppressClick = useRef(false)
const samples = useRef<{x: number; time: number}[]>([])
useLayoutEffect(() => {
for (const animation of track.current?.getAnimations() ?? []) {
animation.cancel()
}
drag.value = null
offset.value = 0
settling.current = false
}, [props.photo.id, drag, offset, settling])
async function _onPointerEnd(event: PointerEvent<HTMLDivElement>) {
Comment thread
aspizu marked this conversation as resolved.
if (drag.value?.pointerId !== event.pointerId) return
const width = event.currentTarget.clientWidth
const recent = samples.current.find((sample) => event.timeStamp - sample.time <= 100)
const elapsed = recent ? event.timeStamp - recent.time : 0
const distance = recent ? event.clientX - recent.x : 0
const velocity = elapsed > 0 ? distance / elapsed : 0
const flick = Math.abs(velocity) >= 0.5 && Math.abs(distance) >= 8
const direction = flick ? velocity : offset.value
const target = direction > 0 ? props.photo.prev : props.photo.next
const shouldNavigate =
event.type === "pointerup" && target && (flick || Math.abs(offset.value) >= width * 0.25)
drag.value = null
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
if (!track.current) {
offset.value = 0
return
}
settling.current = true
const destination = shouldNavigate ? (direction > 0 ? width : -width) : 0
const animation = track.current.animate(
[
{transform: `translateX(${offset.value}px)`},
{transform: `translateX(${destination}px)`},
],
{
duration: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 200,
easing: "ease-out",
fill: "forwards",
},
)
try {
await animation.finished
if (!shouldNavigate) return
await navigate({
to: props.album ? "/a/$album/p/$photo" : "/p/$photo",
params: props.album ? {album: props.album.id, photo: target} : {photo: target},
replace: true,
viewTransition: false,
})
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return
console.error(error)
} finally {
animation.cancel()
offset.value = 0
settling.current = false
}
}
return (
<div className="relative min-h-0 grow overflow-hidden">
{props.photo.prev && (
<AdjacentPhoto
key={props.photo.prev}
id={props.photo.prev}
viewer={props}
side="prev"
/>
<div
className={cn(
"relative min-h-0 grow overflow-hidden",
!props.captionEditable && "touch-pan-y touch-pinch-zoom select-none",
!props.captionEditable && (drag.value ? "cursor-grabbing" : "cursor-grab"),
)}
<div className="absolute inset-0 flex">
<Photo key={props.photo.id} {...props} />
onPointerDown={(event) => {
suppressClick.current = false
if (
!event.isPrimary ||
event.button !== 0 ||
props.captionEditable ||
drag.value ||
settling.current
)
return
if (
event.target instanceof Element &&
event.target.closest("a, button, input, textarea, select, [contenteditable]")
)
return
document.activeViewTransition?.skipTransition()
event.currentTarget.setPointerCapture(event.pointerId)
Comment thread
aspizu marked this conversation as resolved.
Outdated
samples.current = [{x: event.clientX, time: event.timeStamp}]
drag.value = {pointerId: event.pointerId, startX: event.clientX}
}}
onPointerMove={(event) => {
if (drag.value?.pointerId !== event.pointerId) return
samples.current = [
...samples.current.filter((sample) => event.timeStamp - sample.time <= 100),
{x: event.clientX, time: event.timeStamp},
]
const distance = event.clientX - drag.value.startX
if (Math.abs(distance) > 5) {
suppressClick.current = true
}
const width = event.currentTarget.clientWidth
const bounded = Math.max(-width, Math.min(width, distance))
const atEdge =
(distance > 0 && !props.photo.prev) || (distance < 0 && !props.photo.next)
offset.value = atEdge ? bounded * 0.2 : bounded
}}
onPointerUp={(event) => {
void _onPointerEnd(event)
}}
onPointerCancel={(event) => {
void _onPointerEnd(event)
}}
onLostPointerCapture={(event) => {
void _onPointerEnd(event)
}}
onClickCapture={(event) => {
if (!suppressClick.current || event.detail === 0) return
event.preventDefault()
event.stopPropagation()
suppressClick.current = false
}}
onDragStart={(event) => {
event.preventDefault()
}}
>
<div
ref={track}
className="absolute inset-0"
style={{transform: `translateX(${offset.value}px)`}}
>
{[props.photo.prev, props.photo.id, props.photo.next].map((id, index) =>
id ? <PhotoSlide key={id} id={id} viewer={props} position={index - 1} /> : null,
)}
</div>
<div
className={cn(
"pointer-events-none absolute inset-0 mb-12 transition-opacity duration-150 motion-reduce:transition-none",
drag.value ? "opacity-0" : "opacity-100 [&_a]:pointer-events-auto",
)}
inert={drag.value !== null}
>
{!props.preview && props.photo.prev && (
<Button
className="absolute top-[50%] left-4 translate-y-[-50%]"
variant="secondary"
size="icon-sm"
asChild
>
<Link
to={props.album ? "/a/$album/p/$photo" : "/p/$photo"}
params={
props.album
? {album: props.album.id, photo: props.photo.prev}
: {photo: props.photo.prev}
}
replace
>
<ArrowLeft />
</Link>
</Button>
)}
{!props.preview && props.photo.next && (
<Button
className="absolute top-[50%] right-4 translate-y-[-50%]"
variant="secondary"
size="icon-sm"
asChild
>
<Link
to={props.album ? "/a/$album/p/$photo" : "/p/$photo"}
params={
props.album
? {album: props.album.id, photo: props.photo.next}
: {photo: props.photo.next}
}
replace
>
<ArrowRight />
</Link>
</Button>
)}
</div>
{props.photo.next && (
<AdjacentPhoto
key={props.photo.next}
id={props.photo.next}
viewer={props}
side="next"
/>
)}
</div>
)
}
58 changes: 2 additions & 56 deletions packages/client/src/components/photo.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import {constants} from "@dcim/common"
import {Link} from "@tanstack/react-router"
import {useWindowSize} from "@uidotdev/usehooks"
import _ from "lodash"
import {ArrowLeft, ArrowRight, Check} from "lucide-react"
import {Check} from "lucide-react"
import {useCallback, useEffect, useRef, useState} from "react"

import {ImgFaded} from "#components/img-faded"
Expand Down Expand Up @@ -162,10 +161,7 @@ export function Photo(props: {
height: `${h}px`,
}}
>
<div
className="relative h-full w-full"
style={{viewTransitionName: `photo-${props.photo.id}`}}
>
<div className="relative h-full w-full">
<img
src={props.photo.thumbhash}
alt=""
Expand Down Expand Up @@ -201,56 +197,6 @@ export function Photo(props: {
)}
</div>
</div>
{!props.preview && props.photo.prev && (
<Button
className="absolute top-[50%] left-4 translate-y-[-50%]"
variant="secondary"
size="icon-lg"
asChild
>
{props.album ? (
<Link
to="/a/$album/p/$photo"
params={{
album: props.album.id,
photo: props.photo.prev,
}}
replace
>
<ArrowLeft />
</Link>
) : (
<Link to="/p/$photo" params={{photo: props.photo.prev}} replace>
<ArrowLeft />
</Link>
)}
</Button>
)}
{!props.preview && props.photo.next && (
<Button
className="absolute top-[50%] right-4 translate-y-[-50%]"
variant="secondary"
size="icon-lg"
asChild
>
{props.album ? (
<Link
to="/a/$album/p/$photo"
params={{
album: props.album.id,
photo: props.photo.next,
}}
replace
>
<ArrowRight />
</Link>
) : (
<Link to="/p/$photo" params={{photo: props.photo.next}} replace>
<ArrowRight />
</Link>
)}
</Button>
)}
</div>
)
}
2 changes: 1 addition & 1 deletion packages/client/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ declare module "@tanstack/react-router" {
export const queryClient = new QueryClient()
const router = createRouter({
routeTree,
defaultViewTransition: true,
defaultViewTransition: false,
scrollRestoration: true,
})

Expand Down
4 changes: 0 additions & 4 deletions packages/client/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,3 @@
@apply font-sans;
}
}

@view-transition {
navigation: auto;
}
Loading