feat(private-room): add image grid and swipe gallery
Render unlocked albums as adaptive nine-image grids and open the selected source image in the Gallery. Add pointer-driven swipe thresholds, edge resistance, animated snapping, adjacent-image loading, reduced-motion support, and coverage for filtering and navigation.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getResistedGalleryDragDistance,
|
||||
resolveGallerySwipeDirection,
|
||||
} from "../private-album-gallery-motion";
|
||||
|
||||
describe("private album Gallery motion", () => {
|
||||
it("switches after crossing the viewport distance threshold", () => {
|
||||
expect(
|
||||
resolveGallerySwipeDirection({
|
||||
distance: -72,
|
||||
durationMs: 500,
|
||||
viewportWidth: 390,
|
||||
hasPrevious: true,
|
||||
hasNext: true,
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(
|
||||
resolveGallerySwipeDirection({
|
||||
distance: 72,
|
||||
durationMs: 500,
|
||||
viewportWidth: 390,
|
||||
hasPrevious: true,
|
||||
hasNext: true,
|
||||
}),
|
||||
).toBe(-1);
|
||||
});
|
||||
|
||||
it("switches on a short fast swipe but not a slow short drag", () => {
|
||||
const baseInput = {
|
||||
distance: -30,
|
||||
viewportWidth: 390,
|
||||
hasPrevious: true,
|
||||
hasNext: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveGallerySwipeDirection({ ...baseInput, durationMs: 50 }),
|
||||
).toBe(1);
|
||||
expect(
|
||||
resolveGallerySwipeDirection({ ...baseInput, durationMs: 500 }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("does not move beyond the first or last image", () => {
|
||||
expect(
|
||||
resolveGallerySwipeDirection({
|
||||
distance: 100,
|
||||
durationMs: 100,
|
||||
viewportWidth: 390,
|
||||
hasPrevious: false,
|
||||
hasNext: true,
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
resolveGallerySwipeDirection({
|
||||
distance: -100,
|
||||
durationMs: 100,
|
||||
viewportWidth: 390,
|
||||
hasPrevious: true,
|
||||
hasNext: false,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("applies resistance only while pulling past an edge", () => {
|
||||
expect(getResistedGalleryDragDistance(100, false, true)).toBeCloseTo(28);
|
||||
expect(getResistedGalleryDragDistance(-100, true, false)).toBeCloseTo(-28);
|
||||
expect(getResistedGalleryDragDistance(-100, false, true)).toBe(-100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PrivateAlbumSchema } from "@/data/schemas/private-room";
|
||||
|
||||
import {
|
||||
findPrivateAlbumDisplayImage,
|
||||
getPrivateAlbumDisplayImages,
|
||||
getPrivateAlbumGridLayout,
|
||||
} from "../private-album-images";
|
||||
|
||||
describe("private album display images", () => {
|
||||
const album = PrivateAlbumSchema.parse({
|
||||
albumId: "album-1",
|
||||
images: [
|
||||
{ url: " /images/photo-1.png ", locked: false, index: 0 },
|
||||
{ url: "/images/photo-2.png", locked: true, index: 1 },
|
||||
{ url: " ", locked: false, index: 2 },
|
||||
{ url: "/images/photo-4.png", locked: false, index: 3 },
|
||||
],
|
||||
unlocked: true,
|
||||
lockDetail: { locked: false },
|
||||
});
|
||||
|
||||
it("filters unavailable images while preserving source indexes", () => {
|
||||
expect(getPrivateAlbumDisplayImages(album)).toEqual([
|
||||
{ sourceIndex: 0, url: "/images/photo-1.png" },
|
||||
{ sourceIndex: 3, url: "/images/photo-4.png" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves Gallery images by their original source index", () => {
|
||||
expect(findPrivateAlbumDisplayImage(album, 3)).toEqual({
|
||||
sourceIndex: 3,
|
||||
url: "/images/photo-4.png",
|
||||
});
|
||||
expect(findPrivateAlbumDisplayImage(album, 1)).toBeNull();
|
||||
expect(findPrivateAlbumDisplayImage(album, 2)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, "empty"],
|
||||
[1, "single"],
|
||||
[2, "double"],
|
||||
[3, "triple"],
|
||||
[4, "double"],
|
||||
[5, "triple"],
|
||||
[9, "triple"],
|
||||
] as const)("uses the expected grid for %i images", (count, layout) => {
|
||||
expect(getPrivateAlbumGridLayout(count)).toBe(layout);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PrivateAlbumSchema } from "@/data/schemas/private-room";
|
||||
|
||||
import { PrivateAlbumCard } from "../private-album-card";
|
||||
|
||||
describe("PrivateAlbumCard interactions", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("opens the selected thumbnail with its original source index", () => {
|
||||
const onOpenGallery = vi.fn();
|
||||
const album = PrivateAlbumSchema.parse({
|
||||
albumId: "album-1",
|
||||
title: "Private afternoon",
|
||||
imageCount: 4,
|
||||
images: [
|
||||
{ url: "", locked: false, index: 0 },
|
||||
{ url: "/images/private-room/locked.png", locked: true, index: 1 },
|
||||
{ url: "/images/private-room/photo-2.png", locked: false, index: 2 },
|
||||
{ url: "/images/private-room/photo-3.png", locked: false, index: 3 },
|
||||
],
|
||||
locked: false,
|
||||
unlocked: true,
|
||||
lockDetail: { locked: false },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<PrivateAlbumCard
|
||||
album={album}
|
||||
displayName="Elio Silvestri"
|
||||
avatarUrl="/images/avatar/elio.png"
|
||||
index={0}
|
||||
isUnlocking={false}
|
||||
onOpenGallery={onOpenGallery}
|
||||
onUnlock={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const thumbnail = container.querySelector<HTMLButtonElement>(
|
||||
'button[data-image-index="3"]',
|
||||
);
|
||||
act(() => thumbnail?.click());
|
||||
|
||||
expect(onOpenGallery).toHaveBeenCalledOnce();
|
||||
expect(onOpenGallery).toHaveBeenCalledWith(3);
|
||||
});
|
||||
});
|
||||
@@ -43,10 +43,10 @@ function renderCard(album: PrivateAlbum): string {
|
||||
}
|
||||
|
||||
describe("PrivateAlbumCard", () => {
|
||||
it("renders only the first unlocked image and its photo count", () => {
|
||||
it("renders every available unlocked image in the adaptive grid", () => {
|
||||
const html = renderCard(
|
||||
makeAlbum({
|
||||
imageCount: 8,
|
||||
imageCount: 2,
|
||||
images: [
|
||||
{ url: COVER_URL, locked: false, index: 0 },
|
||||
{
|
||||
@@ -59,13 +59,48 @@ describe("PrivateAlbumCard", () => {
|
||||
);
|
||||
|
||||
expect(html).toContain("elio.png");
|
||||
expect(html).not.toContain("%2Fimages%2Fcover%2Felio.png");
|
||||
expect(html).toContain("8 photos");
|
||||
expect(html).toContain("%2Fimages%2Fcover%2Felio.png");
|
||||
expect(html).toContain('data-grid-layout="double"');
|
||||
expect(html).toContain('data-image-index="0"');
|
||||
expect(html).toContain('data-image-index="1"');
|
||||
expect(html).toContain("A quiet morning by the water.");
|
||||
expect(html).toContain('aria-label="Open 8 private album photos"');
|
||||
expect(html).toContain(
|
||||
'aria-label="Open photo 2 of 2 from Elio Silvestri"',
|
||||
);
|
||||
expect(html).toContain('data-analytics-key="private_album.open_gallery"');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ count: 1, layout: "single" },
|
||||
{ count: 2, layout: "double" },
|
||||
{ count: 3, layout: "triple" },
|
||||
{ count: 4, layout: "double" },
|
||||
{ count: 5, layout: "triple" },
|
||||
{ count: 9, layout: "triple" },
|
||||
])("uses the $layout layout for $count images", ({ count, layout }) => {
|
||||
const html = renderCard(
|
||||
makeAlbum({
|
||||
imageCount: count,
|
||||
images: makeImages(count),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain(`data-grid-layout="${layout}"`);
|
||||
});
|
||||
|
||||
it("limits the preview to nine images and shows the remaining count", () => {
|
||||
const html = renderCard(
|
||||
makeAlbum({
|
||||
imageCount: 12,
|
||||
images: makeImages(12),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html.match(/data-image-index=/g)).toHaveLength(9);
|
||||
expect(html).toContain("+3");
|
||||
expect(html).not.toContain('data-image-index="9"');
|
||||
});
|
||||
|
||||
it("uses the backend first image as the locked cover", () => {
|
||||
const html = renderCard(
|
||||
makeAlbum({
|
||||
@@ -93,3 +128,11 @@ describe("PrivateAlbumCard", () => {
|
||||
expect(html).toContain("No photo available");
|
||||
});
|
||||
});
|
||||
|
||||
function makeImages(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
url: `/images/private-room/photo-${index}.png`,
|
||||
locked: false,
|
||||
index,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PrivateAlbumSchema } from "@/data/schemas/private-room";
|
||||
|
||||
import { PrivateAlbumGallery } from "../private-album-gallery";
|
||||
|
||||
const album = PrivateAlbumSchema.parse({
|
||||
albumId: "album-1",
|
||||
title: "Private afternoon",
|
||||
imageCount: 5,
|
||||
images: [
|
||||
{ url: "/images/private-room/photo-0.png", locked: false, index: 0 },
|
||||
{ url: "", locked: false, index: 1 },
|
||||
{ url: "/images/private-room/locked.png", locked: true, index: 2 },
|
||||
{ url: "/images/private-room/photo-3.png", locked: false, index: 3 },
|
||||
{ url: "/images/private-room/photo-4.png", locked: false, index: 4 },
|
||||
],
|
||||
locked: false,
|
||||
unlocked: true,
|
||||
lockDetail: { locked: false },
|
||||
});
|
||||
|
||||
describe("PrivateAlbumGallery", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let onClose: () => void;
|
||||
let onImageIndexChange: (index: number) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
Object.defineProperties(HTMLElement.prototype, {
|
||||
setPointerCapture: {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
},
|
||||
hasPointerCapture: {
|
||||
configurable: true,
|
||||
value: vi.fn(() => true),
|
||||
},
|
||||
releasePointerCapture: {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
},
|
||||
});
|
||||
onClose = vi.fn();
|
||||
onImageIndexChange = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("follows a drag and switches to the next available source index", () => {
|
||||
renderGallery(0);
|
||||
const viewport = getViewport();
|
||||
Object.defineProperty(viewport, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 390,
|
||||
});
|
||||
|
||||
dispatchPointer(viewport, "pointerdown", 300);
|
||||
dispatchPointer(viewport, "pointermove", 200);
|
||||
expect(viewport.querySelector<HTMLElement>("[aria-hidden=false]")?.style.getPropertyValue("--gallery-drag-offset")).toBe("-100px");
|
||||
dispatchPointer(viewport, "pointerup", 200);
|
||||
|
||||
expect(onImageIndexChange).toHaveBeenCalledWith(3);
|
||||
expect(container.textContent).toContain("2 / 3");
|
||||
});
|
||||
|
||||
it("snaps back on pointer cancellation without changing images", () => {
|
||||
renderGallery(3);
|
||||
const viewport = getViewport();
|
||||
Object.defineProperty(viewport, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 390,
|
||||
});
|
||||
|
||||
dispatchPointer(viewport, "pointerdown", 300);
|
||||
dispatchPointer(viewport, "pointermove", 200);
|
||||
dispatchPointer(viewport, "pointercancel", 200);
|
||||
|
||||
expect(onImageIndexChange).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("2 / 3");
|
||||
});
|
||||
|
||||
it("uses the same available indexes for controls and keyboard navigation", () => {
|
||||
renderGallery(3);
|
||||
|
||||
act(() => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('button[aria-label="Next photo"]')
|
||||
?.click();
|
||||
});
|
||||
expect(onImageIndexChange).toHaveBeenLastCalledWith(4);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(onImageIndexChange).toHaveBeenLastCalledWith(3);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
function renderGallery(imageIndex: number): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<PrivateAlbumGallery
|
||||
album={album}
|
||||
imageIndex={imageIndex}
|
||||
onClose={onClose}
|
||||
onImageIndexChange={onImageIndexChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getViewport(): HTMLDivElement {
|
||||
const viewport = container.querySelector<HTMLDivElement>(
|
||||
'[data-testid="private-album-gallery-viewport"]',
|
||||
);
|
||||
if (!viewport) throw new Error("Missing Gallery viewport");
|
||||
return viewport;
|
||||
}
|
||||
});
|
||||
|
||||
function dispatchPointer(
|
||||
target: HTMLElement,
|
||||
type: "pointerdown" | "pointermove" | "pointerup" | "pointercancel",
|
||||
clientX: number,
|
||||
): void {
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
clientX,
|
||||
});
|
||||
Object.defineProperty(event, "pointerId", { value: 1 });
|
||||
act(() => target.dispatchEvent(event));
|
||||
}
|
||||
@@ -6,6 +6,12 @@ import { CharacterAvatar } from "@/app/_components";
|
||||
import type { PrivateAlbum } from "@/data/schemas/private-room";
|
||||
import { isPrivateAlbumLocked } from "@/lib/private-room/private_album";
|
||||
|
||||
import {
|
||||
getPrivateAlbumDisplayImages,
|
||||
getPrivateAlbumGridLayout,
|
||||
PRIVATE_ALBUM_GRID_LIMIT,
|
||||
} from "../private-album-images";
|
||||
|
||||
import styles from "../private-room-screen.module.css";
|
||||
|
||||
export interface PrivateAlbumCardProps {
|
||||
@@ -14,7 +20,7 @@ export interface PrivateAlbumCardProps {
|
||||
avatarUrl: string;
|
||||
index: number;
|
||||
isUnlocking: boolean;
|
||||
onOpenGallery: () => void;
|
||||
onOpenGallery: (imageIndex: number) => void;
|
||||
onUnlock: () => void;
|
||||
}
|
||||
|
||||
@@ -31,6 +37,19 @@ export function PrivateAlbumCard({
|
||||
const firstImage = album.images[0];
|
||||
const firstImageUrl = firstImage?.url || null;
|
||||
const photoCount = album.imageCount || album.images.length;
|
||||
const displayImages = getPrivateAlbumDisplayImages(album);
|
||||
const previewImages = displayImages.slice(0, PRIVATE_ALBUM_GRID_LIMIT);
|
||||
const gridLayout = getPrivateAlbumGridLayout(previewImages.length);
|
||||
const overflowCount = Math.max(
|
||||
0,
|
||||
displayImages.length - PRIVATE_ALBUM_GRID_LIMIT,
|
||||
);
|
||||
const gridLayoutClassName =
|
||||
gridLayout === "single"
|
||||
? styles.mediaGridSingle
|
||||
: gridLayout === "double"
|
||||
? styles.mediaGridDouble
|
||||
: styles.mediaGridTriple;
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -92,30 +111,39 @@ export function PrivateAlbumCard({
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
) : firstImageUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-key="private_album.open_gallery"
|
||||
data-analytics-label="Open private album gallery"
|
||||
className={styles.mediaCover}
|
||||
onClick={onOpenGallery}
|
||||
aria-label={`Open ${photoCount} private album photos`}
|
||||
) : previewImages.length > 0 ? (
|
||||
<div
|
||||
className={`${styles.mediaGrid} ${gridLayoutClassName}`}
|
||||
data-grid-layout={gridLayout}
|
||||
data-image-count={displayImages.length}
|
||||
>
|
||||
<Image
|
||||
src={firstImageUrl}
|
||||
alt={`${album.title || displayName} private album`}
|
||||
width={720}
|
||||
height={900}
|
||||
className={styles.momentCoverImage}
|
||||
sizes="(max-width: 540px) calc(100vw - 36px), 484px"
|
||||
/>
|
||||
{photoCount > 1 ? (
|
||||
<span className={styles.mediaCountBadge}>
|
||||
<ImageIcon size={13} aria-hidden="true" />
|
||||
{photoCount} photos
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{previewImages.map((image, imagePosition) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${image.sourceIndex}:${image.url}`}
|
||||
data-analytics-key="private_album.open_gallery"
|
||||
data-analytics-label="Open private album gallery"
|
||||
data-image-index={image.sourceIndex}
|
||||
className={styles.mediaGridItem}
|
||||
onClick={() => onOpenGallery(image.sourceIndex)}
|
||||
aria-label={`Open photo ${imagePosition + 1} of ${displayImages.length} from ${displayName}`}
|
||||
>
|
||||
<Image
|
||||
src={image.url}
|
||||
alt=""
|
||||
fill
|
||||
sizes={getGridImageSizes(gridLayout)}
|
||||
className={styles.mediaGridImage}
|
||||
/>
|
||||
{imagePosition === PRIVATE_ALBUM_GRID_LIMIT - 1 &&
|
||||
overflowCount > 0 ? (
|
||||
<span className={styles.mediaGridOverflow} aria-hidden="true">
|
||||
+{overflowCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={styles.emptyMediaCover}
|
||||
@@ -132,6 +160,12 @@ export function PrivateAlbumCard({
|
||||
);
|
||||
}
|
||||
|
||||
function getGridImageSizes(layout: string): string {
|
||||
if (layout === "single") return "(max-width: 540px) 68vw, 280px";
|
||||
if (layout === "double") return "(max-width: 540px) 34vw, 154px";
|
||||
return "(max-width: 540px) 29vw, 150px";
|
||||
}
|
||||
|
||||
function formatAlbumTime(value: string | null): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Image from "next/image";
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
|
||||
import type { PrivateAlbum } from "@/data/schemas/private-room";
|
||||
|
||||
import {
|
||||
getResistedGalleryDragDistance,
|
||||
resolveGallerySwipeDirection,
|
||||
} from "../private-album-gallery-motion";
|
||||
import { getPrivateAlbumDisplayImages } from "../private-album-images";
|
||||
import styles from "../private-room-screen.module.css";
|
||||
|
||||
export interface PrivateAlbumGalleryProps {
|
||||
@@ -15,41 +27,137 @@ export interface PrivateAlbumGalleryProps {
|
||||
onImageIndexChange: (index: number) => void;
|
||||
}
|
||||
|
||||
interface ActivePointerGesture {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startTime: number;
|
||||
startPosition: number;
|
||||
viewportWidth: number;
|
||||
}
|
||||
|
||||
type GallerySlideStyle = CSSProperties & {
|
||||
"--gallery-slide-offset": string;
|
||||
"--gallery-drag-offset": string;
|
||||
};
|
||||
|
||||
export function PrivateAlbumGallery({
|
||||
album,
|
||||
imageIndex,
|
||||
onClose,
|
||||
onImageIndexChange,
|
||||
}: PrivateAlbumGalleryProps) {
|
||||
const touchStartXRef = useRef<number | null>(null);
|
||||
const currentImage = album.images[imageIndex];
|
||||
const hasPrevious = imageIndex > 0;
|
||||
const hasNext = imageIndex < album.images.length - 1;
|
||||
const displayImages = useMemo(
|
||||
() => getPrivateAlbumDisplayImages(album),
|
||||
[album],
|
||||
);
|
||||
const [activeSourceIndex, setActiveSourceIndex] = useState(imageIndex);
|
||||
const [dragOffset, setDragOffset] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const gestureRef = useRef<ActivePointerGesture | null>(null);
|
||||
const activePosition = displayImages.findIndex(
|
||||
(image) => image.sourceIndex === activeSourceIndex,
|
||||
);
|
||||
const currentImage = displayImages[activePosition];
|
||||
const hasPrevious = activePosition > 0;
|
||||
const hasNext =
|
||||
activePosition >= 0 && activePosition < displayImages.length - 1;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
if (event.key === "ArrowLeft" && hasPrevious) {
|
||||
onImageIndexChange(imageIndex - 1);
|
||||
const previousImage = displayImages[activePosition - 1];
|
||||
if (previousImage) {
|
||||
setActiveSourceIndex(previousImage.sourceIndex);
|
||||
onImageIndexChange(previousImage.sourceIndex);
|
||||
}
|
||||
}
|
||||
if (event.key === "ArrowRight" && hasNext) {
|
||||
onImageIndexChange(imageIndex + 1);
|
||||
const nextImage = displayImages[activePosition + 1];
|
||||
if (nextImage) {
|
||||
setActiveSourceIndex(nextImage.sourceIndex);
|
||||
onImageIndexChange(nextImage.sourceIndex);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [hasNext, hasPrevious, imageIndex, onClose, onImageIndexChange]);
|
||||
}, [
|
||||
activePosition,
|
||||
displayImages,
|
||||
hasNext,
|
||||
hasPrevious,
|
||||
onClose,
|
||||
onImageIndexChange,
|
||||
]);
|
||||
|
||||
if (!currentImage?.url) return null;
|
||||
if (!currentImage) return null;
|
||||
|
||||
const handleTouchEnd = (event: React.TouchEvent) => {
|
||||
const touchStartX = touchStartXRef.current;
|
||||
touchStartXRef.current = null;
|
||||
if (touchStartX === null) return;
|
||||
const delta = event.changedTouches[0]?.clientX ?? touchStartX;
|
||||
const distance = delta - touchStartX;
|
||||
if (distance > 48 && hasPrevious) onImageIndexChange(imageIndex - 1);
|
||||
if (distance < -48 && hasNext) onImageIndexChange(imageIndex + 1);
|
||||
const changeToPosition = (position: number) => {
|
||||
const nextImage = displayImages[position];
|
||||
if (!nextImage || position === activePosition) return;
|
||||
setActiveSourceIndex(nextImage.sourceIndex);
|
||||
setDragOffset(0);
|
||||
setIsDragging(false);
|
||||
onImageIndexChange(nextImage.sourceIndex);
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || displayImages.length <= 1) return;
|
||||
const viewportWidth = event.currentTarget.clientWidth || window.innerWidth;
|
||||
gestureRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startTime: event.timeStamp,
|
||||
startPosition: activePosition,
|
||||
viewportWidth,
|
||||
};
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDragOffset(0);
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
const distance = event.clientX - gesture.startX;
|
||||
setDragOffset(
|
||||
getResistedGalleryDragDistance(
|
||||
distance,
|
||||
gesture.startPosition > 0,
|
||||
gesture.startPosition < displayImages.length - 1,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const finishPointerGesture = (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
cancelled: boolean,
|
||||
) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
gestureRef.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
const distance = event.clientX - gesture.startX;
|
||||
const direction = cancelled
|
||||
? 0
|
||||
: resolveGallerySwipeDirection({
|
||||
distance,
|
||||
durationMs: event.timeStamp - gesture.startTime,
|
||||
viewportWidth: gesture.viewportWidth,
|
||||
hasPrevious: gesture.startPosition > 0,
|
||||
hasNext: gesture.startPosition < displayImages.length - 1,
|
||||
});
|
||||
|
||||
setIsDragging(false);
|
||||
setDragOffset(0);
|
||||
if (direction !== 0) {
|
||||
changeToPosition(gesture.startPosition + direction);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -58,10 +166,6 @@ export function PrivateAlbumGallery({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={album.title || "Private album gallery"}
|
||||
onTouchStart={(event) => {
|
||||
touchStartXRef.current = event.touches[0]?.clientX ?? null;
|
||||
}}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -72,27 +176,54 @@ export function PrivateAlbumGallery({
|
||||
<X size={24} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div className={styles.galleryImageFrame}>
|
||||
<Image
|
||||
src={currentImage.url}
|
||||
alt={`${album.title || "Private album"} photo ${imageIndex + 1}`}
|
||||
fill
|
||||
priority
|
||||
draggable={false}
|
||||
sizes="(max-width: 540px) 100vw, 540px"
|
||||
className={styles.galleryImage}
|
||||
/>
|
||||
<div
|
||||
className={styles.galleryViewport}
|
||||
data-testid="private-album-gallery-viewport"
|
||||
data-dragging={isDragging ? "true" : "false"}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={(event) => finishPointerGesture(event, false)}
|
||||
onPointerCancel={(event) => finishPointerGesture(event, true)}
|
||||
onLostPointerCapture={(event) => finishPointerGesture(event, true)}
|
||||
>
|
||||
{displayImages.map((image, position) => {
|
||||
if (Math.abs(position - activePosition) > 1) return null;
|
||||
const slideStyle = {
|
||||
"--gallery-slide-offset": `${(position - activePosition) * 100}%`,
|
||||
"--gallery-drag-offset": `${dragOffset}px`,
|
||||
} as GallerySlideStyle;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${image.sourceIndex}:${image.url}`}
|
||||
className={styles.gallerySlide}
|
||||
style={slideStyle}
|
||||
aria-hidden={position !== activePosition}
|
||||
>
|
||||
<Image
|
||||
src={image.url}
|
||||
alt={`${album.title || "Private album"} photo ${position + 1}`}
|
||||
fill
|
||||
loading="eager"
|
||||
fetchPriority={position === activePosition ? "high" : "auto"}
|
||||
draggable={false}
|
||||
sizes="(max-width: 540px) 100vw, 540px"
|
||||
className={styles.galleryImage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<span className={styles.galleryCounter}>
|
||||
{imageIndex + 1} / {album.images.length}
|
||||
<span className={styles.galleryCounter} aria-live="polite">
|
||||
{activePosition + 1} / {displayImages.length}
|
||||
</span>
|
||||
|
||||
{hasPrevious ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.galleryNav} ${styles.galleryPrevious}`}
|
||||
onClick={() => onImageIndexChange(imageIndex - 1)}
|
||||
onClick={() => changeToPosition(activePosition - 1)}
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<ChevronLeft size={30} aria-hidden="true" />
|
||||
@@ -102,7 +233,7 @@ export function PrivateAlbumGallery({
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.galleryNav} ${styles.galleryNext}`}
|
||||
onClick={() => onImageIndexChange(imageIndex + 1)}
|
||||
onClick={() => changeToPosition(activePosition + 1)}
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<ChevronRight size={30} aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
export const GALLERY_DISTANCE_RATIO = 0.18;
|
||||
export const GALLERY_MIN_DISTANCE_PX = 56;
|
||||
export const GALLERY_FAST_SWIPE_MIN_DISTANCE_PX = 24;
|
||||
export const GALLERY_FAST_SWIPE_VELOCITY_PX_PER_MS = 0.45;
|
||||
export const GALLERY_EDGE_RESISTANCE = 0.28;
|
||||
|
||||
export type GallerySwipeDirection = -1 | 0 | 1;
|
||||
|
||||
export interface ResolveGallerySwipeInput {
|
||||
distance: number;
|
||||
durationMs: number;
|
||||
viewportWidth: number;
|
||||
hasPrevious: boolean;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
export function getResistedGalleryDragDistance(
|
||||
distance: number,
|
||||
hasPrevious: boolean,
|
||||
hasNext: boolean,
|
||||
): number {
|
||||
if (distance > 0 && !hasPrevious) {
|
||||
return distance * GALLERY_EDGE_RESISTANCE;
|
||||
}
|
||||
if (distance < 0 && !hasNext) {
|
||||
return distance * GALLERY_EDGE_RESISTANCE;
|
||||
}
|
||||
return distance;
|
||||
}
|
||||
|
||||
export function resolveGallerySwipeDirection({
|
||||
distance,
|
||||
durationMs,
|
||||
viewportWidth,
|
||||
hasPrevious,
|
||||
hasNext,
|
||||
}: ResolveGallerySwipeInput): GallerySwipeDirection {
|
||||
if (distance === 0) return 0;
|
||||
|
||||
const absoluteDistance = Math.abs(distance);
|
||||
const distanceThreshold = Math.max(
|
||||
GALLERY_MIN_DISTANCE_PX,
|
||||
Math.max(0, viewportWidth) * GALLERY_DISTANCE_RATIO,
|
||||
);
|
||||
const velocity = absoluteDistance / Math.max(1, durationMs);
|
||||
const passedDistanceThreshold = absoluteDistance >= distanceThreshold;
|
||||
const passedVelocityThreshold =
|
||||
absoluteDistance >= GALLERY_FAST_SWIPE_MIN_DISTANCE_PX &&
|
||||
velocity >= GALLERY_FAST_SWIPE_VELOCITY_PX_PER_MS;
|
||||
|
||||
if (!passedDistanceThreshold && !passedVelocityThreshold) return 0;
|
||||
if (distance > 0) return hasPrevious ? -1 : 0;
|
||||
return hasNext ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { PrivateAlbum } from "@/data/schemas/private-room";
|
||||
|
||||
export const PRIVATE_ALBUM_GRID_LIMIT = 9;
|
||||
|
||||
export interface PrivateAlbumDisplayImage {
|
||||
readonly sourceIndex: number;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export type PrivateAlbumGridLayout =
|
||||
| "empty"
|
||||
| "single"
|
||||
| "double"
|
||||
| "triple";
|
||||
|
||||
export function getPrivateAlbumDisplayImages(
|
||||
album: PrivateAlbum,
|
||||
): readonly PrivateAlbumDisplayImage[] {
|
||||
return album.images.flatMap((image, sourceIndex) => {
|
||||
const url = image.url.trim();
|
||||
if (image.locked || url.length === 0) return [];
|
||||
return [{ sourceIndex, url }];
|
||||
});
|
||||
}
|
||||
|
||||
export function findPrivateAlbumDisplayImage(
|
||||
album: PrivateAlbum,
|
||||
sourceIndex: number,
|
||||
): PrivateAlbumDisplayImage | null {
|
||||
return (
|
||||
getPrivateAlbumDisplayImages(album).find(
|
||||
(image) => image.sourceIndex === sourceIndex,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function getPrivateAlbumGridLayout(
|
||||
imageCount: number,
|
||||
): PrivateAlbumGridLayout {
|
||||
if (imageCount <= 0) return "empty";
|
||||
if (imageCount === 1) return "single";
|
||||
if (imageCount === 2 || imageCount === 4) return "double";
|
||||
return "triple";
|
||||
}
|
||||
@@ -384,47 +384,64 @@
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.mediaCover {
|
||||
.mediaGrid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.mediaGridSingle {
|
||||
width: min(72%, 280px);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mediaGridDouble {
|
||||
width: min(72%, 320px);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.mediaGridTriple {
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.mediaGridItem {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
border-radius: clamp(20px, 5.185vw, 28px);
|
||||
border-radius: 6px;
|
||||
background: #f3eef0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: inherit;
|
||||
box-shadow: 0 14px 32px rgba(131, 72, 85, 0.14);
|
||||
transition: filter 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.momentCoverImage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
.mediaGridSingle .mediaGridItem {
|
||||
aspect-ratio: 4 / 5;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mediaCountBadge {
|
||||
.mediaGridImage {
|
||||
object-fit: cover;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mediaGridOverflow {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: inline-flex;
|
||||
min-height: 30px;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 999px;
|
||||
background: rgba(28, 19, 23, 0.72);
|
||||
justify-content: center;
|
||||
background: rgba(24, 18, 21, 0.56);
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 8px 18px rgba(28, 19, 23, 0.18);
|
||||
backdrop-filter: blur(12px);
|
||||
font-size: clamp(22px, 6.296vw, 34px);
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.02em;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.emptyMediaCover {
|
||||
@@ -474,12 +491,37 @@
|
||||
overflow: hidden;
|
||||
background: #090909;
|
||||
color: #ffffff;
|
||||
touch-action: pan-y;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.galleryImageFrame {
|
||||
.galleryViewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: pan-y;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.galleryViewport[data-dragging="true"] {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.gallerySlide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
transform: translate3d(
|
||||
calc(var(--gallery-slide-offset, 0%) + var(--gallery-drag-offset, 0px)),
|
||||
0,
|
||||
0
|
||||
);
|
||||
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.galleryViewport[data-dragging="true"] .gallerySlide {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.galleryImage {
|
||||
@@ -630,7 +672,7 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mediaCover:hover {
|
||||
.mediaGridItem:hover {
|
||||
filter: brightness(1.02);
|
||||
}
|
||||
|
||||
@@ -644,7 +686,7 @@
|
||||
transform: translateY(1px) scale(0.99);
|
||||
}
|
||||
|
||||
.mediaCover:active {
|
||||
.mediaGridItem:active {
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
@@ -658,7 +700,7 @@
|
||||
|
||||
.primaryCta:focus-visible,
|
||||
.lockedPreview:focus-visible,
|
||||
.mediaCover:focus-visible,
|
||||
.mediaGridItem:focus-visible,
|
||||
.galleryClose:focus-visible,
|
||||
.galleryNav:focus-visible,
|
||||
.dialogPrimary:focus-visible,
|
||||
@@ -690,3 +732,9 @@
|
||||
transform: scale(1.01);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gallerySlide {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
buildPrivateRoomWithoutGalleryUrl,
|
||||
getPrivateAlbumGalleryState,
|
||||
} from "./private-album-gallery-url";
|
||||
import { findPrivateAlbumDisplayImage } from "./private-album-images";
|
||||
|
||||
export function PrivateRoomScreen() {
|
||||
const router = useRouter();
|
||||
@@ -87,14 +88,23 @@ export function PrivateRoomScreen() {
|
||||
: null,
|
||||
[galleryState, state.items],
|
||||
);
|
||||
const galleryImage = useMemo(
|
||||
() =>
|
||||
galleryAlbum && galleryState
|
||||
? findPrivateAlbumDisplayImage(
|
||||
galleryAlbum,
|
||||
galleryState.imageIndex,
|
||||
)
|
||||
: null,
|
||||
[galleryAlbum, galleryState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!galleryState || state.isLoading) return;
|
||||
const image = galleryAlbum?.images[galleryState.imageIndex];
|
||||
if (
|
||||
!galleryAlbum ||
|
||||
isPrivateAlbumLocked(galleryAlbum) ||
|
||||
!image?.url
|
||||
!galleryImage
|
||||
) {
|
||||
router.replace(
|
||||
buildPrivateRoomWithoutGalleryUrl(
|
||||
@@ -107,6 +117,7 @@ export function PrivateRoomScreen() {
|
||||
}, [
|
||||
characterRoutes.privateRoom,
|
||||
galleryAlbum,
|
||||
galleryImage,
|
||||
galleryState,
|
||||
router,
|
||||
searchParams,
|
||||
@@ -128,10 +139,14 @@ export function PrivateRoomScreen() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenGallery = (albumId: string) => {
|
||||
const handleOpenGallery = (albumId: string, imageIndex: number) => {
|
||||
openedGalleryInPageRef.current = true;
|
||||
router.push(
|
||||
buildPrivateAlbumGalleryUrl(albumId, 0, characterRoutes.privateRoom),
|
||||
buildPrivateAlbumGalleryUrl(
|
||||
albumId,
|
||||
imageIndex,
|
||||
characterRoutes.privateRoom,
|
||||
),
|
||||
{ scroll: false },
|
||||
);
|
||||
};
|
||||
@@ -247,7 +262,9 @@ export function PrivateRoomScreen() {
|
||||
avatarUrl={avatarUrl}
|
||||
index={index}
|
||||
isUnlocking={state.unlockingAlbumId === album.albumId}
|
||||
onOpenGallery={() => handleOpenGallery(album.albumId)}
|
||||
onOpenGallery={(imageIndex) =>
|
||||
handleOpenGallery(album.albumId, imageIndex)
|
||||
}
|
||||
onUnlock={() =>
|
||||
dispatch({
|
||||
type: "PrivateRoomUnlockRequested",
|
||||
@@ -285,7 +302,7 @@ export function PrivateRoomScreen() {
|
||||
{galleryAlbum &&
|
||||
galleryState &&
|
||||
!isPrivateAlbumLocked(galleryAlbum) &&
|
||||
galleryAlbum.images[galleryState.imageIndex]?.url ? (
|
||||
galleryImage ? (
|
||||
<PrivateAlbumGallery
|
||||
album={galleryAlbum}
|
||||
imageIndex={galleryState.imageIndex}
|
||||
|
||||
Reference in New Issue
Block a user