diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx
index 68a707b..20f4a40 100644
--- a/docker/frontend/src/Library.tsx
+++ b/docker/frontend/src/Library.tsx
@@ -2216,6 +2216,20 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
setView('preview');
};
+ // The frame next to this one, on the shelf's own order: the arrows at the
+ // edges of the stage and the swipe in the mobile preview both walk the list the
+ // reader is looking at, so what is drawn after the step is what was beside it.
+ const stepFrame = useCallback(
+ (d: number) => {
+ if (!frame) return;
+ const i = listed.findIndex((p) => p.id === frame.id);
+ if (i < 0) return;
+ const next = listed[i + d];
+ if (next) setUp(next.id);
+ },
+ [frame?.id, listed],
+ );
+
// A frame's score: click a star to give it, click the star it already has to
// take the score back. The catalogue holds it, so it is the same score the next
// visit reads — and the list on screen is told, rather than read back whole for
@@ -2469,6 +2483,9 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const gradedTile = frame && edited.has(frame.id) ? urls[frame.id] ?? null : null;
const stageSrc = gradedTile ?? (stageUrl && stageFor === frame?.id ? stageUrl : frame ? urls[frame.id] ?? null : null);
const stageTurned = useTurned(stageSrc, frame?.rot ?? 0);
+ // Where the open frame stands on the shelf, so the arrows at the stage's edges
+ // know which of them has nothing to go to.
+ const frameAt = listed.findIndex((p) => p.id === frame?.id);
@@ -3185,6 +3202,50 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
touchAction: 'none',
}}
>
+ {/* The frame's neighbours, at the edges the eye already
+ reads as "there is more this way". Both are drawn
+ whenever the shelf holds another frame, and the one
+ with nothing to go to is dimmed rather than taken
+ away, so the pair does not jump about. They are the
+ stage's own overlay: a press on one must not reach
+ the zoom, the pan or the open under it. */}
+ {listed.length > 1 && frameAt >= 0 ? (
+ <>
+ {(
+ [
+ { d: -1, key: 'lib-prev', edge: { left: '10px' }, label: t('lib.prev'), icon: 'M10 2 4 8l6 6' },
+ { d: 1, key: 'lib-next', edge: { right: '10px' }, label: t('lib.next'), icon: 'M6 2l6 6-6 6' },
+ ] as const
+ ).map((a) => {
+ const at = frameAt + a.d;
+ const off = at < 0 || at >= listed.length;
+ return (
+
+ );
+ })}
+ >
+ ) : null}
{stageTurned ? (
.lib-noimg { flex: 1 1 auto; min-height: 0; aspect-ratio: auto; }
.lib-root .adm-stage > .adm-meta,
.lib-root .adm-stage > .chip-row { flex: 0 0 auto; }
diff --git a/docker/frontend/src/ui/MobileLibrary.tsx b/docker/frontend/src/ui/MobileLibrary.tsx
index 082fb47..a15a0e1 100644
--- a/docker/frontend/src/ui/MobileLibrary.tsx
+++ b/docker/frontend/src/ui/MobileLibrary.tsx
@@ -584,6 +584,18 @@ export function MobileLibrary({
return groups;
}, [visiblePhotos, lang]);
+ // The frame a swipe lands on: the list the reader is looking at is the shelf,
+ // so the step goes the way that list is read and stops at its ends.
+ const stepPhoto = (d: number) => {
+ if (!selectedPhoto) return;
+ const i = searchResults.findIndex((p) => p.id === selectedPhoto.id);
+ const next = i < 0 ? undefined : searchResults[i + d];
+ if (next) setSelectedPhoto(next);
+ };
+ // Where the finger went down: only the distance it travelled matters, and only
+ // once it has lifted.
+ const swipeFrom = useRef<{ x: number; y: number } | null>(null);
+
// Filtered photos in Search tab
const searchResults = useMemo(() => {
let list = photos;
@@ -2243,7 +2255,28 @@ export function MobileLibrary({
-