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
15 changed files with 822 additions and 101 deletions
+8 -2
View File
@@ -208,13 +208,19 @@ Paywall 导航发起后立即消费当前请求,避免 React 重渲染重复
任一条件不满足时,页面通过 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 与数据边界
- React key 使用稳定 `albumId`
- 卡片图片数量优先使用 `imageCount`,为 0 时回退到 `images.length`
- Gallery 只渲染当前索引存在 URL 的图片
- 锁定相册继续只显示模糊封面和解锁入口
- 解锁相册过滤锁定或空 URL 图片,并保留剩余图片的原始数组索引;
- 1 张图片显示 4:5 大图,2/4 张使用两列,其余使用三列正方形九宫格;
- 超过 9 张时卡片显示前九张,末格显示 `+N`,Gallery 仍可浏览全部有效图片;
- Gallery 只挂载当前图片和相邻图片,避免多图相册同时解码全部原图;
- `creditBalance` 是当前列表/解锁响应快照,不替代 User Store 权益;
- Private Room 不使用 Chat 的 `conversationKey`、消息缓存或媒体缓存;
- Private Room 不持有 Payment ActorTop-up 通过路由级导航进入独立 Payment Provider。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 35 KiB

@@ -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 ? (
) : previewImages.length > 0 ? (
<div
className={`${styles.mediaGrid} ${gridLayoutClassName}`}
data-grid-layout={gridLayout}
data-image-count={displayImages.length}
>
{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"
className={styles.mediaCover}
onClick={onOpenGallery}
aria-label={`Open ${photoCount} private album photos`}
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={firstImageUrl}
alt={`${album.title || displayName} private album`}
width={720}
height={900}
className={styles.momentCoverImage}
sizes="(max-width: 540px) calc(100vw - 36px), 484px"
src={image.url}
alt=""
fill
sizes={getGridImageSizes(gridLayout)}
className={styles.mediaGridImage}
/>
{photoCount > 1 ? (
<span className={styles.mediaCountBadge}>
<ImageIcon size={13} aria-hidden="true" />
{photoCount} photos
{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}>
<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={currentImage.url}
alt={`${album.title || "Private album"} photo ${imageIndex + 1}`}
src={image.url}
alt={`${album.title || "Private album"} photo ${position + 1}`}
fill
priority
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;
}
}
+23 -6
View File
@@ -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}