Files
cozsweet-frontend-nextjs/src/app/private-zoom/components/private-album-card.tsx
T

200 lines
6.6 KiB
TypeScript

import type { CSSProperties } from "react";
import Image from "next/image";
import { Copy, ImageIcon, LockKeyhole } from "lucide-react";
import { CharacterAvatar } from "@/app/_components";
import type { PrivateAlbum } from "@/data/schemas/private-zoom";
import { isPrivateAlbumLocked } from "@/lib/private-zoom/private_album";
import {
getPrivateAlbumDisplayImages,
getPrivateAlbumGridLayout,
PRIVATE_ALBUM_GRID_LIMIT,
} from "../private-album-images";
import styles from "../private-zoom-screen.module.css";
export interface PrivateAlbumCardProps {
album: PrivateAlbum;
displayName: string;
avatarUrl: string;
index: number;
isUnlocking: boolean;
onOpenGallery: (imageIndex: number) => void;
onUnlock: () => void;
}
export function PrivateAlbumCard({
album,
displayName,
avatarUrl,
index,
isUnlocking,
onOpenGallery,
onUnlock,
}: PrivateAlbumCardProps) {
const isLocked = isPrivateAlbumLocked(album);
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
className={styles.postCard}
style={{ "--reveal-index": index } as CSSProperties}
>
<header className={styles.postHeader}>
<div className={styles.postAuthor}>
<CharacterAvatar
src={avatarUrl}
alt=""
size={38}
imageSize={38}
className={styles.postAvatar}
/>
<div>
<p className={styles.authorName}>{displayName}</p>
<p className={styles.authorSubline}>
{album.title || "Private album"}
</p>
</div>
</div>
<time className={styles.postTime}>
{formatAlbumTime(album.publishedAt)}
</time>
</header>
{isLocked ? (
<div className={styles.lockedPreview}>
<div className={styles.lockedPreviewBackdrop} aria-hidden="true">
{firstImageUrl ? (
<Image
src={firstImageUrl}
alt=""
fill
sizes="(max-width: 540px) calc(100vw - 36px), 484px"
className={styles.lockedCoverImage}
/>
) : null}
<span className={styles.lockedPreviewScrim} />
</div>
<div className={styles.lockedPreviewPanel}>
<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={72}
className={styles.lockedPreviewAvatar}
/>
<span className={styles.lockedAvatarBadge} aria-hidden="true">
<LockKeyhole size={15} 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>
</div>
) : 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"
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={image.url}
alt=""
fill
sizes={getGridImageSizes(gridLayout)}
className={styles.mediaGridImage}
/>
{imagePosition === PRIVATE_ALBUM_GRID_LIMIT - 1 &&
overflowCount > 0 ? (
<span className={styles.mediaGridOverflow} aria-hidden="true">
+{overflowCount}
</span>
) : null}
</button>
))}
</div>
) : (
<div
className={styles.emptyMediaCover}
role="img"
aria-label="No private album photo available"
>
<ImageIcon size={32} aria-hidden="true" />
<span>No photo available</span>
</div>
)}
{album.content ? <p className={styles.postText}>{album.content}</p> : null}
</article>
);
}
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);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}