feat(private-room): redesign locked album cards
This commit is contained in:
@@ -216,7 +216,7 @@ Gallery 只浏览 `locked=false` 且 URL 非空的图片,但 URL 中的 `image
|
|||||||
|
|
||||||
- React key 使用稳定 `albumId`;
|
- React key 使用稳定 `albumId`;
|
||||||
- 卡片图片数量优先使用 `imageCount`,为 0 时回退到 `images.length`;
|
- 卡片图片数量优先使用 `imageCount`,为 0 时回退到 `images.length`;
|
||||||
- 锁定相册继续只显示模糊封面和解锁入口;
|
- 锁定相册显示模糊封面、角色头像、锁标识、图片数量和 `View collection` 入口;卡片不展示视频数量或积分价格,解锁价格只在确认 Dialog 中展示;
|
||||||
- 解锁相册过滤锁定或空 URL 图片,并保留剩余图片的原始数组索引;
|
- 解锁相册过滤锁定或空 URL 图片,并保留剩余图片的原始数组索引;
|
||||||
- 1 张图片显示 4:5 大图,2/4 张使用两列,其余使用三列正方形九宫格;
|
- 1 张图片显示 4:5 大图,2/4 张使用两列,其余使用三列正方形九宫格;
|
||||||
- 超过 9 张时卡片显示前九张,末格显示 `+N`,Gallery 仍可浏览全部有效图片;
|
- 超过 9 张时卡片显示前九张,末格显示 `+N`,Gallery 仍可浏览全部有效图片;
|
||||||
|
|||||||
@@ -64,4 +64,68 @@ describe("PrivateAlbumCard interactions", () => {
|
|||||||
expect(onOpenGallery).toHaveBeenCalledOnce();
|
expect(onOpenGallery).toHaveBeenCalledOnce();
|
||||||
expect(onOpenGallery).toHaveBeenCalledWith(3);
|
expect(onOpenGallery).toHaveBeenCalledWith(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("unlocks only from the collection CTA and disables repeat actions", () => {
|
||||||
|
const onUnlock = vi.fn();
|
||||||
|
const album = PrivateAlbumSchema.parse({
|
||||||
|
albumId: "album-locked",
|
||||||
|
title: "Locked afternoon",
|
||||||
|
imageCount: 3,
|
||||||
|
images: [
|
||||||
|
{ url: "/images/private-room/locked.png", locked: true, index: 0 },
|
||||||
|
],
|
||||||
|
locked: true,
|
||||||
|
unlocked: false,
|
||||||
|
unlockCost: 40,
|
||||||
|
lockDetail: { locked: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderLockedCard(album, false, onUnlock);
|
||||||
|
const avatar = container.querySelector<HTMLElement>(
|
||||||
|
'[aria-label="Elio Silvestri locked collection"]',
|
||||||
|
);
|
||||||
|
act(() => avatar?.parentElement?.click());
|
||||||
|
expect(onUnlock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const button = findCollectionButton();
|
||||||
|
act(() => button.click());
|
||||||
|
expect(onUnlock).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
renderLockedCard(album, true, onUnlock);
|
||||||
|
const disabledButton = findCollectionButton();
|
||||||
|
expect(disabledButton.disabled).toBe(true);
|
||||||
|
expect(disabledButton.textContent).toBe("Opening...");
|
||||||
|
act(() => disabledButton.click());
|
||||||
|
expect(onUnlock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderLockedCard(
|
||||||
|
album: ReturnType<typeof PrivateAlbumSchema.parse>,
|
||||||
|
isUnlocking: boolean,
|
||||||
|
onUnlock: () => void,
|
||||||
|
): void {
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<PrivateAlbumCard
|
||||||
|
album={album}
|
||||||
|
displayName="Elio Silvestri"
|
||||||
|
avatarUrl="/images/avatar/elio.png"
|
||||||
|
index={0}
|
||||||
|
isUnlocking={isUnlocking}
|
||||||
|
onOpenGallery={() => undefined}
|
||||||
|
onUnlock={onUnlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCollectionButton(): HTMLButtonElement {
|
||||||
|
const button = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(item) =>
|
||||||
|
item.textContent === "View collection" ||
|
||||||
|
item.textContent === "Opening...",
|
||||||
|
);
|
||||||
|
if (!button) throw new Error("Missing View collection button");
|
||||||
|
return button;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ describe("PrivateAlbumCard", () => {
|
|||||||
expect(html).not.toContain('data-image-index="9"');
|
expect(html).not.toContain('data-image-index="9"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the backend first image as the locked cover", () => {
|
it("renders the reference-style locked collection over the first image", () => {
|
||||||
const html = renderCard(
|
const html = renderCard(
|
||||||
makeAlbum({
|
makeAlbum({
|
||||||
content: null,
|
content: null,
|
||||||
@@ -114,13 +114,39 @@ describe("PrivateAlbumCard", () => {
|
|||||||
|
|
||||||
expect(html).toContain("elio.png");
|
expect(html).toContain("elio.png");
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'aria-label="Unlock 8 private room photos from Elio Silvestri"',
|
'aria-label="Elio Silvestri locked collection"',
|
||||||
);
|
);
|
||||||
expect(html).toContain("Only for you.");
|
expect(html).toContain("lucide-copy");
|
||||||
expect(html).toContain("320 credits");
|
expect(html).toContain("lucide-lock-keyhole");
|
||||||
|
expect(html).toContain("Unlock to view");
|
||||||
|
expect(html).toContain("8 Images");
|
||||||
|
expect(html).toContain("View collection");
|
||||||
|
expect(html).toContain(
|
||||||
|
'aria-label="View locked collection with 8 images from Elio Silvestri"',
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("Only for you.");
|
||||||
|
expect(html).not.toContain("320 credits");
|
||||||
|
expect(html).not.toContain("Videos");
|
||||||
expect(html).toContain('data-analytics-key="private_album.unlock"');
|
expect(html).toContain('data-analytics-key="private_album.unlock"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses singular image copy and works without a cover", () => {
|
||||||
|
const html = renderCard(
|
||||||
|
makeAlbum({
|
||||||
|
imageCount: 0,
|
||||||
|
locked: true,
|
||||||
|
unlocked: false,
|
||||||
|
lockDetail: { locked: true },
|
||||||
|
images: [{ url: "", locked: true, index: 0 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("1 Image");
|
||||||
|
expect(html).not.toContain("1 Images");
|
||||||
|
expect(html.match(/<img/g)).toHaveLength(2);
|
||||||
|
expect(html).toContain("View collection");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders an empty cover when an unlocked album has no image", () => {
|
it("renders an empty cover when an unlocked album has no image", () => {
|
||||||
const html = renderCard(makeAlbum());
|
const html = renderCard(makeAlbum());
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { ImageIcon, LockKeyhole } from "lucide-react";
|
import { Copy, ImageIcon, LockKeyhole } from "lucide-react";
|
||||||
|
|
||||||
import { CharacterAvatar } from "@/app/_components";
|
import { CharacterAvatar } from "@/app/_components";
|
||||||
import type { PrivateAlbum } from "@/data/schemas/private-room";
|
import type { PrivateAlbum } from "@/data/schemas/private-room";
|
||||||
@@ -78,39 +78,59 @@ export function PrivateAlbumCard({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{isLocked ? (
|
{isLocked ? (
|
||||||
<button
|
<div className={styles.lockedPreview}>
|
||||||
type="button"
|
<div className={styles.lockedPreviewBackdrop} aria-hidden="true">
|
||||||
data-analytics-key="private_album.unlock"
|
{firstImageUrl ? (
|
||||||
data-analytics-label="Unlock private album"
|
<Image
|
||||||
className={styles.lockedPreview}
|
src={firstImageUrl}
|
||||||
disabled={isUnlocking}
|
alt=""
|
||||||
onClick={onUnlock}
|
fill
|
||||||
aria-label={`Unlock ${photoCount} private room photos from ${displayName}`}
|
sizes="(max-width: 540px) calc(100vw - 36px), 484px"
|
||||||
>
|
className={styles.lockedCoverImage}
|
||||||
{firstImageUrl ? (
|
/>
|
||||||
<Image
|
) : null}
|
||||||
src={firstImageUrl}
|
<span className={styles.lockedPreviewScrim} />
|
||||||
alt=""
|
|
||||||
fill
|
|
||||||
sizes="(max-width: 540px) calc(100vw - 36px), 484px"
|
|
||||||
className={styles.lockedCoverImage}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<span className={styles.lockedPreviewScrim} aria-hidden="true" />
|
|
||||||
<div className={styles.lockedPreviewContent}>
|
|
||||||
<div className={styles.previewIcon}>
|
|
||||||
<LockKeyhole size={22} aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<div className={styles.previewText}>
|
|
||||||
<span>{album.previewText || "Unlock to view"}</span>
|
|
||||||
<strong>{album.unlockCost} credits</strong>
|
|
||||||
<small>
|
|
||||||
<ImageIcon size={13} aria-hidden="true" />
|
|
||||||
{photoCount} {photoCount === 1 ? "photo" : "photos"}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<span className={styles.lockedCollectionIcon} aria-hidden="true">
|
||||||
|
<Copy size={17} strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={styles.lockedPreviewAvatarFrame}
|
||||||
|
role="img"
|
||||||
|
aria-label={`${displayName} locked collection`}
|
||||||
|
>
|
||||||
|
<CharacterAvatar
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
size="100%"
|
||||||
|
imageSize={96}
|
||||||
|
className={styles.lockedPreviewAvatar}
|
||||||
|
/>
|
||||||
|
<span className={styles.lockedAvatarBadge} aria-hidden="true">
|
||||||
|
<LockKeyhole size={18} strokeWidth={2.2} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.lockedPreviewContent}>
|
||||||
|
<strong className={styles.lockedPreviewTitle}>Unlock to view</strong>
|
||||||
|
<span className={styles.lockedImageCount}>
|
||||||
|
<ImageIcon size={19} strokeWidth={1.9} aria-hidden="true" />
|
||||||
|
{photoCount} {photoCount === 1 ? "Image" : "Images"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-analytics-key="private_album.unlock"
|
||||||
|
data-analytics-label="Unlock private album"
|
||||||
|
className={styles.lockedPreviewCta}
|
||||||
|
disabled={isUnlocking}
|
||||||
|
onClick={onUnlock}
|
||||||
|
aria-label={`View locked collection with ${photoCount} ${photoCount === 1 ? "image" : "images"} from ${displayName}`}
|
||||||
|
>
|
||||||
|
{isUnlocking ? "Opening..." : "View collection"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : previewImages.length > 0 ? (
|
) : previewImages.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className={`${styles.mediaGrid} ${gridLayoutClassName}`}
|
className={`${styles.mediaGrid} ${gridLayoutClassName}`}
|
||||||
|
|||||||
@@ -289,99 +289,148 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4 / 5;
|
min-height: clamp(230px, 56vw, 280px);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 14px;
|
margin-top: clamp(54px, 13.333vw, 68px);
|
||||||
padding: clamp(16px, 4.074vw, 22px);
|
padding: clamp(66px, 16.296vw, 82px) clamp(18px, 4.63vw, 25px)
|
||||||
border: 1px solid rgba(255, 116, 159, 0.24);
|
clamp(22px, 5.556vw, 30px);
|
||||||
border-radius: clamp(20px, 5.185vw, 28px);
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: clamp(20px, 5.185vw, 26px);
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 22% 18%, rgba(255, 255, 255, 0.94), transparent 29%),
|
radial-gradient(circle at 74% 12%, rgba(255, 178, 115, 0.28), transparent 42%),
|
||||||
radial-gradient(circle at 82% 82%, rgba(255, 103, 224, 0.18), transparent 35%),
|
linear-gradient(145deg, #47434d, #29262d);
|
||||||
linear-gradient(145deg, rgba(255, 246, 239, 0.98), rgba(255, 225, 239, 0.94)),
|
color: #ffffff;
|
||||||
#ffffff;
|
isolation: isolate;
|
||||||
color: #21171b;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
overflow: hidden;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.82),
|
inset 0 1px 0 rgba(255, 255, 255, 0.12),
|
||||||
0 14px 32px rgba(255, 116, 159, 0.14);
|
0 16px 34px rgba(34, 25, 29, 0.18);
|
||||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lockedPreview:disabled {
|
.lockedPreviewBackdrop {
|
||||||
cursor: wait;
|
position: absolute;
|
||||||
opacity: 0.72;
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lockedPreviewContent {
|
.lockedPreviewContent {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 3;
|
||||||
display: flex;
|
display: flex;
|
||||||
max-width: 260px;
|
width: min(100%, 300px);
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lockedCoverImage {
|
.lockedCoverImage {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
filter: blur(18px);
|
filter: blur(16px) saturate(0.78);
|
||||||
transform: scale(1.08);
|
transform: scale(1.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lockedPreviewScrim {
|
.lockedPreviewScrim {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background: rgba(28, 19, 23, 0.46);
|
background:
|
||||||
|
linear-gradient(180deg, rgba(18, 17, 21, 0.3), rgba(18, 17, 21, 0.68)),
|
||||||
|
rgba(31, 28, 34, 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewIcon {
|
.lockedCollectionIcon {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
left: 14px;
|
||||||
|
z-index: 3;
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 58px;
|
width: 28px;
|
||||||
height: 58px;
|
height: 28px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.68);
|
border: 1px solid rgba(255, 255, 255, 0.36);
|
||||||
border-radius: 20px;
|
border-radius: 7px;
|
||||||
background: linear-gradient(135deg, #ff7ac3, var(--color-accent, #f84d96));
|
background: rgba(24, 23, 28, 0.52);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
box-shadow: 0 14px 28px rgba(248, 77, 150, 0.26);
|
box-shadow: 0 5px 14px rgba(0, 0, 0, 0.2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewText {
|
.lockedPreviewAvatarFrame {
|
||||||
display: flex;
|
position: absolute;
|
||||||
min-width: 0;
|
top: clamp(-45px, -9.259vw, -36px);
|
||||||
flex-direction: column;
|
left: 50%;
|
||||||
align-items: center;
|
z-index: 4;
|
||||||
gap: 8px;
|
width: clamp(82px, 20.741vw, 100px);
|
||||||
text-align: center;
|
height: clamp(82px, 20.741vw, 100px);
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.94);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 12px 28px rgba(29, 21, 25, 0.28);
|
||||||
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewText strong {
|
.lockedPreviewAvatar {
|
||||||
color: #21171b;
|
border-radius: 999px;
|
||||||
font-size: clamp(16px, 4.074vw, 21px);
|
|
||||||
font-weight: 930;
|
|
||||||
line-height: 1.05;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewText span {
|
.lockedAvatarBadge {
|
||||||
color: #4c3a40;
|
position: absolute;
|
||||||
font-size: clamp(13px, 3.333vw, 17px);
|
right: -3px;
|
||||||
font-weight: 780;
|
bottom: 0;
|
||||||
|
display: grid;
|
||||||
|
width: clamp(30px, 7.778vw, 38px);
|
||||||
|
height: clamp(30px, 7.778vw, 38px);
|
||||||
|
place-items: center;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.94);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #29262d;
|
||||||
|
box-shadow: 0 6px 14px rgba(22, 17, 19, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewText small {
|
.lockedPreviewTitle {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: clamp(19px, 4.815vw, 26px);
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.15;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockedImageCount {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 5px;
|
gap: 7px;
|
||||||
color: #a94c64;
|
color: rgba(255, 255, 255, 0.92);
|
||||||
font-size: clamp(12px, 2.963vw, 15px);
|
font-size: clamp(14px, 3.333vw, 17px);
|
||||||
font-weight: 760;
|
font-weight: 620;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockedPreviewCta {
|
||||||
|
min-width: min(100%, 178px);
|
||||||
|
min-height: 46px;
|
||||||
|
margin-top: 22px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #28252a;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: clamp(14px, 3.519vw, 17px);
|
||||||
|
font-weight: 850;
|
||||||
|
box-shadow: 0 10px 24px rgba(18, 14, 16, 0.22);
|
||||||
|
transition: transform 0.16s ease, box-shadow 0.16s ease,
|
||||||
|
background 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockedPreviewCta:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mediaGrid {
|
.mediaGrid {
|
||||||
@@ -666,12 +715,17 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primaryCta:hover,
|
.primaryCta:hover {
|
||||||
.lockedPreview:hover {
|
|
||||||
filter: brightness(1.04);
|
filter: brightness(1.04);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lockedPreviewCta:hover:not(:disabled) {
|
||||||
|
background: #f7f5f6;
|
||||||
|
box-shadow: 0 12px 28px rgba(18, 14, 16, 0.28);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.mediaGridItem:hover {
|
.mediaGridItem:hover {
|
||||||
filter: brightness(1.02);
|
filter: brightness(1.02);
|
||||||
}
|
}
|
||||||
@@ -681,8 +735,11 @@
|
|||||||
background: rgba(38, 38, 38, 0.82);
|
background: rgba(38, 38, 38, 0.82);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primaryCta:active,
|
.primaryCta:active {
|
||||||
.lockedPreview:active {
|
transform: translateY(1px) scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockedPreviewCta:active:not(:disabled) {
|
||||||
transform: translateY(1px) scale(0.99);
|
transform: translateY(1px) scale(0.99);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,7 +756,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.primaryCta:focus-visible,
|
.primaryCta:focus-visible,
|
||||||
.lockedPreview:focus-visible,
|
.lockedPreviewCta:focus-visible,
|
||||||
.mediaGridItem:focus-visible,
|
.mediaGridItem:focus-visible,
|
||||||
.galleryClose:focus-visible,
|
.galleryClose:focus-visible,
|
||||||
.galleryNav:focus-visible,
|
.galleryNav:focus-visible,
|
||||||
|
|||||||
Reference in New Issue
Block a user