Compare commits

..

2 Commits

Author SHA1 Message Date
admin b59422772f chore(favicon): 使用预发布环境的图标
Docker Image / Quality and Bundle Budgets (push) Successful in 3s
Docker Image / Build and Push Docker Image (push) Successful in 1m57s
2026-07-21 18:28:14 +08:00
admin 9fff5b16fd 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.
2026-07-21 18:26:12 +08:00
12 changed files with 822 additions and 101 deletions
+8 -2
View File
@@ -208,13 +208,19 @@ Paywall 导航发起后立即消费当前请求,避免 React 重渲染重复
任一条件不满足时,页面通过 replace 删除 `album``image`,并保留其他查询参数。 任一条件不满足时,页面通过 replace 删除 `album``image`,并保留其他查询参数。
页面内点击打开 Gallery 时,关闭操作优先使用浏览器 back;直接刷新或外部分享 Gallery URL 时,关闭操作使用 replace 返回当前角色 Private Room。键盘 Escape 关闭,左右方向键和横向滑动切换图片。 页面内点击九宫格缩略图时,Gallery 从该图片的后端数组原始索引打开。关闭操作优先使用浏览器 back;直接刷新或外部分享 Gallery URL 时,关闭操作使用 replace 返回当前角色 Private Room。
Gallery 只浏览 `locked=false` 且 URL 非空的图片,但 URL 中的 `image` 仍使用原始数组索引。横向拖动时图片跟随指针,达到视口宽度 18%(最低 56px),或达到 0.45px/ms 且至少移动 24px 时切换;首尾越界拖动使用 0.28 阻尼且不循环。松手后使用 240ms 横向吸附动画,键盘方向键和左右按钮复用相同切换逻辑。Escape 关闭;`prefers-reduced-motion` 下取消吸附过渡。
## 7. UI 与数据边界 ## 7. UI 与数据边界
- React key 使用稳定 `albumId` - React key 使用稳定 `albumId`
- 卡片图片数量优先使用 `imageCount`,为 0 时回退到 `images.length` - 卡片图片数量优先使用 `imageCount`,为 0 时回退到 `images.length`
- Gallery 只渲染当前索引存在 URL 的图片 - 锁定相册继续只显示模糊封面和解锁入口
- 解锁相册过滤锁定或空 URL 图片,并保留剩余图片的原始数组索引;
- 1 张图片显示 4:5 大图,2/4 张使用两列,其余使用三列正方形九宫格;
- 超过 9 张时卡片显示前九张,末格显示 `+N`,Gallery 仍可浏览全部有效图片;
- Gallery 只挂载当前图片和相邻图片,避免多图相册同时解码全部原图;
- `creditBalance` 是当前列表/解锁响应快照,不替代 User Store 权益; - `creditBalance` 是当前列表/解锁响应快照,不替代 User Store 权益;
- Private Room 不使用 Chat 的 `conversationKey`、消息缓存或媒体缓存; - Private Room 不使用 Chat 的 `conversationKey`、消息缓存或媒体缓存;
- Private Room 不持有 Payment ActorTop-up 通过路由级导航进入独立 Payment Provider。 - Private Room 不持有 Payment ActorTop-up 通过路由级导航进入独立 Payment Provider。
@@ -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", () => { 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( const html = renderCard(
makeAlbum({ makeAlbum({
imageCount: 8, imageCount: 2,
images: [ images: [
{ url: COVER_URL, locked: false, index: 0 }, { url: COVER_URL, locked: false, index: 0 },
{ {
@@ -59,13 +59,48 @@ describe("PrivateAlbumCard", () => {
); );
expect(html).toContain("elio.png"); expect(html).toContain("elio.png");
expect(html).not.toContain("%2Fimages%2Fcover%2Felio.png"); expect(html).toContain("%2Fimages%2Fcover%2Felio.png");
expect(html).toContain("8 photos"); 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("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"'); 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", () => { it("uses the backend first image as the locked cover", () => {
const html = renderCard( const html = renderCard(
makeAlbum({ makeAlbum({
@@ -93,3 +128,11 @@ describe("PrivateAlbumCard", () => {
expect(html).toContain("No photo available"); 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 type { PrivateAlbum } from "@/data/schemas/private-room";
import { isPrivateAlbumLocked } from "@/lib/private-room/private_album"; 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"; import styles from "../private-room-screen.module.css";
export interface PrivateAlbumCardProps { export interface PrivateAlbumCardProps {
@@ -14,7 +20,7 @@ export interface PrivateAlbumCardProps {
avatarUrl: string; avatarUrl: string;
index: number; index: number;
isUnlocking: boolean; isUnlocking: boolean;
onOpenGallery: () => void; onOpenGallery: (imageIndex: number) => void;
onUnlock: () => void; onUnlock: () => void;
} }
@@ -31,6 +37,19 @@ export function PrivateAlbumCard({
const firstImage = album.images[0]; const firstImage = album.images[0];
const firstImageUrl = firstImage?.url || null; const firstImageUrl = firstImage?.url || null;
const photoCount = album.imageCount || album.images.length; 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 ( return (
<article <article
@@ -92,30 +111,39 @@ export function PrivateAlbumCard({
</div> </div>
</div> </div>
</button> </button>
) : firstImageUrl ? ( ) : previewImages.length > 0 ? (
<button <div
type="button" className={`${styles.mediaGrid} ${gridLayoutClassName}`}
data-analytics-key="private_album.open_gallery" data-grid-layout={gridLayout}
data-analytics-label="Open private album gallery" data-image-count={displayImages.length}
className={styles.mediaCover}
onClick={onOpenGallery}
aria-label={`Open ${photoCount} private album photos`}
> >
<Image {previewImages.map((image, imagePosition) => (
src={firstImageUrl} <button
alt={`${album.title || displayName} private album`} type="button"
width={720} key={`${image.sourceIndex}:${image.url}`}
height={900} data-analytics-key="private_album.open_gallery"
className={styles.momentCoverImage} data-analytics-label="Open private album gallery"
sizes="(max-width: 540px) calc(100vw - 36px), 484px" data-image-index={image.sourceIndex}
/> className={styles.mediaGridItem}
{photoCount > 1 ? ( onClick={() => onOpenGallery(image.sourceIndex)}
<span className={styles.mediaCountBadge}> aria-label={`Open photo ${imagePosition + 1} of ${displayImages.length} from ${displayName}`}
<ImageIcon size={13} aria-hidden="true" /> >
{photoCount} photos <Image
</span> src={image.url}
) : null} alt=""
</button> 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 <div
className={styles.emptyMediaCover} 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 { function formatAlbumTime(value: string | null): string {
if (!value) return ""; if (!value) return "";
const date = new Date(value); const date = new Date(value);
@@ -1,11 +1,23 @@
"use client"; "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 Image from "next/image";
import { ChevronLeft, ChevronRight, X } from "lucide-react"; import { ChevronLeft, ChevronRight, X } from "lucide-react";
import type { PrivateAlbum } from "@/data/schemas/private-room"; 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"; import styles from "../private-room-screen.module.css";
export interface PrivateAlbumGalleryProps { export interface PrivateAlbumGalleryProps {
@@ -15,41 +27,137 @@ export interface PrivateAlbumGalleryProps {
onImageIndexChange: (index: number) => void; 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({ export function PrivateAlbumGallery({
album, album,
imageIndex, imageIndex,
onClose, onClose,
onImageIndexChange, onImageIndexChange,
}: PrivateAlbumGalleryProps) { }: PrivateAlbumGalleryProps) {
const touchStartXRef = useRef<number | null>(null); const displayImages = useMemo(
const currentImage = album.images[imageIndex]; () => getPrivateAlbumDisplayImages(album),
const hasPrevious = imageIndex > 0; [album],
const hasNext = imageIndex < album.images.length - 1; );
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(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose(); if (event.key === "Escape") onClose();
if (event.key === "ArrowLeft" && hasPrevious) { 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) { 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); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("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 changeToPosition = (position: number) => {
const touchStartX = touchStartXRef.current; const nextImage = displayImages[position];
touchStartXRef.current = null; if (!nextImage || position === activePosition) return;
if (touchStartX === null) return; setActiveSourceIndex(nextImage.sourceIndex);
const delta = event.changedTouches[0]?.clientX ?? touchStartX; setDragOffset(0);
const distance = delta - touchStartX; setIsDragging(false);
if (distance > 48 && hasPrevious) onImageIndexChange(imageIndex - 1); onImageIndexChange(nextImage.sourceIndex);
if (distance < -48 && hasNext) onImageIndexChange(imageIndex + 1); };
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 ( return (
@@ -58,10 +166,6 @@ export function PrivateAlbumGallery({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={album.title || "Private album gallery"} aria-label={album.title || "Private album gallery"}
onTouchStart={(event) => {
touchStartXRef.current = event.touches[0]?.clientX ?? null;
}}
onTouchEnd={handleTouchEnd}
> >
<button <button
type="button" type="button"
@@ -72,27 +176,54 @@ export function PrivateAlbumGallery({
<X size={24} aria-hidden="true" /> <X size={24} aria-hidden="true" />
</button> </button>
<div className={styles.galleryImageFrame}> <div
<Image className={styles.galleryViewport}
src={currentImage.url} data-testid="private-album-gallery-viewport"
alt={`${album.title || "Private album"} photo ${imageIndex + 1}`} data-dragging={isDragging ? "true" : "false"}
fill onPointerDown={handlePointerDown}
priority onPointerMove={handlePointerMove}
draggable={false} onPointerUp={(event) => finishPointerGesture(event, false)}
sizes="(max-width: 540px) 100vw, 540px" onPointerCancel={(event) => finishPointerGesture(event, true)}
className={styles.galleryImage} 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> </div>
<span className={styles.galleryCounter}> <span className={styles.galleryCounter} aria-live="polite">
{imageIndex + 1} / {album.images.length} {activePosition + 1} / {displayImages.length}
</span> </span>
{hasPrevious ? ( {hasPrevious ? (
<button <button
type="button" type="button"
className={`${styles.galleryNav} ${styles.galleryPrevious}`} className={`${styles.galleryNav} ${styles.galleryPrevious}`}
onClick={() => onImageIndexChange(imageIndex - 1)} onClick={() => changeToPosition(activePosition - 1)}
aria-label="Previous photo" aria-label="Previous photo"
> >
<ChevronLeft size={30} aria-hidden="true" /> <ChevronLeft size={30} aria-hidden="true" />
@@ -102,7 +233,7 @@ export function PrivateAlbumGallery({
<button <button
type="button" type="button"
className={`${styles.galleryNav} ${styles.galleryNext}`} className={`${styles.galleryNav} ${styles.galleryNext}`}
onClick={() => onImageIndexChange(imageIndex + 1)} onClick={() => changeToPosition(activePosition + 1)}
aria-label="Next photo" aria-label="Next photo"
> >
<ChevronRight size={30} aria-hidden="true" /> <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; 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; position: relative;
display: block; display: block;
width: 100%; width: 100%;
margin-top: 14px; aspect-ratio: 1;
padding: 0; padding: 0;
border: 0; border: 0;
overflow: hidden; overflow: hidden;
border-radius: clamp(20px, 5.185vw, 28px); border-radius: 6px;
background: #f3eef0; background: #f3eef0;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
text-align: inherit; transition: filter 0.16s ease, transform 0.16s ease;
box-shadow: 0 14px 32px rgba(131, 72, 85, 0.14);
} }
.momentCoverImage { .mediaGridSingle .mediaGridItem {
display: block;
width: 100%;
height: auto;
aspect-ratio: 4 / 5; aspect-ratio: 4 / 5;
object-fit: cover;
} }
.mediaCountBadge { .mediaGridImage {
object-fit: cover;
user-select: none;
}
.mediaGridOverflow {
position: absolute; position: absolute;
top: 12px; inset: 0;
right: 12px; z-index: 1;
display: inline-flex; display: flex;
min-height: 30px;
align-items: center; align-items: center;
gap: 6px; justify-content: center;
padding: 0 11px; background: rgba(24, 18, 21, 0.56);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 999px;
background: rgba(28, 19, 23, 0.72);
color: #ffffff; color: #ffffff;
font-size: 12px; font-size: clamp(22px, 6.296vw, 34px);
font-weight: 800; font-weight: 760;
box-shadow: 0 8px 18px rgba(28, 19, 23, 0.18); letter-spacing: -0.02em;
backdrop-filter: blur(12px); text-shadow: 0 2px 12px rgba(0, 0, 0, 0.34);
} }
.emptyMediaCover { .emptyMediaCover {
@@ -474,12 +491,37 @@
overflow: hidden; overflow: hidden;
background: #090909; background: #090909;
color: #ffffff; color: #ffffff;
touch-action: pan-y; overscroll-behavior: contain;
} }
.galleryImageFrame { .galleryViewport {
position: absolute; position: absolute;
inset: 0; 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 { .galleryImage {
@@ -630,7 +672,7 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.mediaCover:hover { .mediaGridItem:hover {
filter: brightness(1.02); filter: brightness(1.02);
} }
@@ -644,7 +686,7 @@
transform: translateY(1px) scale(0.99); transform: translateY(1px) scale(0.99);
} }
.mediaCover:active { .mediaGridItem:active {
transform: scale(0.995); transform: scale(0.995);
} }
@@ -658,7 +700,7 @@
.primaryCta:focus-visible, .primaryCta:focus-visible,
.lockedPreview:focus-visible, .lockedPreview:focus-visible,
.mediaCover:focus-visible, .mediaGridItem:focus-visible,
.galleryClose:focus-visible, .galleryClose:focus-visible,
.galleryNav:focus-visible, .galleryNav:focus-visible,
.dialogPrimary:focus-visible, .dialogPrimary:focus-visible,
@@ -690,3 +732,9 @@
transform: scale(1.01); transform: scale(1.01);
} }
} }
@media (prefers-reduced-motion: reduce) {
.gallerySlide {
transition: none;
}
}
+23 -6
View File
@@ -36,6 +36,7 @@ import {
buildPrivateRoomWithoutGalleryUrl, buildPrivateRoomWithoutGalleryUrl,
getPrivateAlbumGalleryState, getPrivateAlbumGalleryState,
} from "./private-album-gallery-url"; } from "./private-album-gallery-url";
import { findPrivateAlbumDisplayImage } from "./private-album-images";
export function PrivateRoomScreen() { export function PrivateRoomScreen() {
const router = useRouter(); const router = useRouter();
@@ -87,14 +88,23 @@ export function PrivateRoomScreen() {
: null, : null,
[galleryState, state.items], [galleryState, state.items],
); );
const galleryImage = useMemo(
() =>
galleryAlbum && galleryState
? findPrivateAlbumDisplayImage(
galleryAlbum,
galleryState.imageIndex,
)
: null,
[galleryAlbum, galleryState],
);
useEffect(() => { useEffect(() => {
if (!galleryState || state.isLoading) return; if (!galleryState || state.isLoading) return;
const image = galleryAlbum?.images[galleryState.imageIndex];
if ( if (
!galleryAlbum || !galleryAlbum ||
isPrivateAlbumLocked(galleryAlbum) || isPrivateAlbumLocked(galleryAlbum) ||
!image?.url !galleryImage
) { ) {
router.replace( router.replace(
buildPrivateRoomWithoutGalleryUrl( buildPrivateRoomWithoutGalleryUrl(
@@ -107,6 +117,7 @@ export function PrivateRoomScreen() {
}, [ }, [
characterRoutes.privateRoom, characterRoutes.privateRoom,
galleryAlbum, galleryAlbum,
galleryImage,
galleryState, galleryState,
router, router,
searchParams, searchParams,
@@ -128,10 +139,14 @@ export function PrivateRoomScreen() {
}); });
}; };
const handleOpenGallery = (albumId: string) => { const handleOpenGallery = (albumId: string, imageIndex: number) => {
openedGalleryInPageRef.current = true; openedGalleryInPageRef.current = true;
router.push( router.push(
buildPrivateAlbumGalleryUrl(albumId, 0, characterRoutes.privateRoom), buildPrivateAlbumGalleryUrl(
albumId,
imageIndex,
characterRoutes.privateRoom,
),
{ scroll: false }, { scroll: false },
); );
}; };
@@ -247,7 +262,9 @@ export function PrivateRoomScreen() {
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
index={index} index={index}
isUnlocking={state.unlockingAlbumId === album.albumId} isUnlocking={state.unlockingAlbumId === album.albumId}
onOpenGallery={() => handleOpenGallery(album.albumId)} onOpenGallery={(imageIndex) =>
handleOpenGallery(album.albumId, imageIndex)
}
onUnlock={() => onUnlock={() =>
dispatch({ dispatch({
type: "PrivateRoomUnlockRequested", type: "PrivateRoomUnlockRequested",
@@ -285,7 +302,7 @@ export function PrivateRoomScreen() {
{galleryAlbum && {galleryAlbum &&
galleryState && galleryState &&
!isPrivateAlbumLocked(galleryAlbum) && !isPrivateAlbumLocked(galleryAlbum) &&
galleryAlbum.images[galleryState.imageIndex]?.url ? ( galleryImage ? (
<PrivateAlbumGallery <PrivateAlbumGallery
album={galleryAlbum} album={galleryAlbum}
imageIndex={galleryState.imageIndex} imageIndex={galleryState.imageIndex}