Compare commits

..

3 Commits

Author SHA1 Message Date
admin f8b26d7578 feat(chat): add composer action menu
Docker Image / Build and Push Docker Image (push) Successful in 1m51s
2026-07-21 19:17:32 +08:00
admin e1fb53eba6 chore(favicon): 使用预发布环境的图标 2026-07-21 19:17:32 +08:00
admin 9a51acd636 feat(private-room): redesign locked album cards 2026-07-21 19:15:35 +08:00
5 changed files with 263 additions and 96 deletions
+1 -1
View File
@@ -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,