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:
@@ -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 Actor,Top-up 通过路由级导航进入独立 Payment Provider。
|
- Private Room 不持有 Payment Actor,Top-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 ? (
|
||||||
|
<div
|
||||||
|
className={`${styles.mediaGrid} ${gridLayoutClassName}`}
|
||||||
|
data-grid-layout={gridLayout}
|
||||||
|
data-image-count={displayImages.length}
|
||||||
|
>
|
||||||
|
{previewImages.map((image, imagePosition) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
key={`${image.sourceIndex}:${image.url}`}
|
||||||
data-analytics-key="private_album.open_gallery"
|
data-analytics-key="private_album.open_gallery"
|
||||||
data-analytics-label="Open private album gallery"
|
data-analytics-label="Open private album gallery"
|
||||||
className={styles.mediaCover}
|
data-image-index={image.sourceIndex}
|
||||||
onClick={onOpenGallery}
|
className={styles.mediaGridItem}
|
||||||
aria-label={`Open ${photoCount} private album photos`}
|
onClick={() => onOpenGallery(image.sourceIndex)}
|
||||||
|
aria-label={`Open photo ${imagePosition + 1} of ${displayImages.length} from ${displayName}`}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={firstImageUrl}
|
src={image.url}
|
||||||
alt={`${album.title || displayName} private album`}
|
alt=""
|
||||||
width={720}
|
fill
|
||||||
height={900}
|
sizes={getGridImageSizes(gridLayout)}
|
||||||
className={styles.momentCoverImage}
|
className={styles.mediaGridImage}
|
||||||
sizes="(max-width: 540px) calc(100vw - 36px), 484px"
|
|
||||||
/>
|
/>
|
||||||
{photoCount > 1 ? (
|
{imagePosition === PRIVATE_ALBUM_GRID_LIMIT - 1 &&
|
||||||
<span className={styles.mediaCountBadge}>
|
overflowCount > 0 ? (
|
||||||
<ImageIcon size={13} aria-hidden="true" />
|
<span className={styles.mediaGridOverflow} aria-hidden="true">
|
||||||
{photoCount} photos
|
+{overflowCount}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</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
|
||||||
|
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
|
<Image
|
||||||
src={currentImage.url}
|
src={image.url}
|
||||||
alt={`${album.title || "Private album"} photo ${imageIndex + 1}`}
|
alt={`${album.title || "Private album"} photo ${position + 1}`}
|
||||||
fill
|
fill
|
||||||
priority
|
loading="eager"
|
||||||
|
fetchPriority={position === activePosition ? "high" : "auto"}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
sizes="(max-width: 540px) 100vw, 540px"
|
sizes="(max-width: 540px) 100vw, 540px"
|
||||||
className={styles.galleryImage}
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user