refactor(private-room): split screen components
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./private-room-post-card";
|
||||||
|
export * from "./status-card";
|
||||||
|
export * from "./unlock-confirm-dialog";
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { ImageIcon, LockKeyhole } from "lucide-react";
|
||||||
|
|
||||||
|
import type { PrivateRoomMoment } from "@/data/dto/private-room";
|
||||||
|
|
||||||
|
import styles from "../private-room-screen.module.css";
|
||||||
|
|
||||||
|
export interface PrivateRoomPostCardProps {
|
||||||
|
moment: PrivateRoomMoment;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
index: number;
|
||||||
|
isUnlocking: boolean;
|
||||||
|
onUnlock: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrivateRoomPostCard({
|
||||||
|
moment,
|
||||||
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
|
index,
|
||||||
|
isUnlocking,
|
||||||
|
onUnlock,
|
||||||
|
}: PrivateRoomPostCardProps) {
|
||||||
|
const isLocked = moment.locked || moment.lockDetail.locked;
|
||||||
|
const imageUrls = moment.images
|
||||||
|
.filter((image) => !image.locked && image.url)
|
||||||
|
.map((image) => image.url as string);
|
||||||
|
const photoCount = moment.mediaCount || moment.images.length || 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={styles.postCard}
|
||||||
|
style={{ "--reveal-index": index } as CSSProperties}
|
||||||
|
>
|
||||||
|
<header className={styles.postHeader}>
|
||||||
|
<div className={styles.postAuthor}>
|
||||||
|
<Image
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
width={38}
|
||||||
|
height={38}
|
||||||
|
className={styles.postAvatar}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className={styles.authorName}>{moment.author.name || displayName}</p>
|
||||||
|
<p className={styles.authorSubline}>
|
||||||
|
{moment.title || "Private room drop"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<time className={styles.postTime}>
|
||||||
|
{moment.timeText || formatMomentTime(moment.publishedAt)}
|
||||||
|
</time>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{moment.text || moment.content ? (
|
||||||
|
<p className={styles.postText}>{moment.text || moment.content}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLocked ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.lockedPreview}
|
||||||
|
disabled={isUnlocking}
|
||||||
|
onClick={onUnlock}
|
||||||
|
aria-label={`Unlock ${photoCount} private room photos from ${displayName}`}
|
||||||
|
>
|
||||||
|
<div className={styles.previewIcon}>
|
||||||
|
<LockKeyhole size={18} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.previewText}>
|
||||||
|
<strong>{moment.unlockCost} credits</strong>
|
||||||
|
<span>{moment.textPreview || "Unlock to view"}</span>
|
||||||
|
<small>
|
||||||
|
<ImageIcon size={13} aria-hidden="true" />
|
||||||
|
{photoCount} {photoCount === 1 ? "photo" : "photos"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className={styles.imageGrid}>
|
||||||
|
{imageUrls.map((url, imageIndex) => (
|
||||||
|
<Image
|
||||||
|
key={`${moment.momentId}-${imageIndex}`}
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
width={420}
|
||||||
|
height={420}
|
||||||
|
className={styles.unlockedImage}
|
||||||
|
sizes="(max-width: 540px) 86vw, 460px"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMomentTime(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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import styles from "../private-room-screen.module.css";
|
||||||
|
|
||||||
|
export interface StatusCardProps {
|
||||||
|
message: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusCard({
|
||||||
|
message,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
}: StatusCardProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.statusCard}>
|
||||||
|
<p>{message}</p>
|
||||||
|
{actionLabel && onAction ? (
|
||||||
|
<button type="button" onClick={onAction}>
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { PrivateRoomMoment } from "@/data/dto/private-room";
|
||||||
|
|
||||||
|
import styles from "../private-room-screen.module.css";
|
||||||
|
|
||||||
|
export interface UnlockConfirmDialogProps {
|
||||||
|
moment: PrivateRoomMoment;
|
||||||
|
isUnlocking: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnlockConfirmDialog({
|
||||||
|
moment,
|
||||||
|
isUnlocking,
|
||||||
|
errorMessage,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: UnlockConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.dialogOverlay} role="alertdialog" aria-modal="true">
|
||||||
|
<div className={styles.dialog}>
|
||||||
|
<h2 className={styles.dialogTitle}>Unlock private moment?</h2>
|
||||||
|
<p className={styles.dialogCopy}>
|
||||||
|
This will use {moment.unlockCost} credits.
|
||||||
|
</p>
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className={styles.dialogError}>{errorMessage}</p>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.dialogActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.dialogSecondary}
|
||||||
|
disabled={isUnlocking}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.dialogPrimary}
|
||||||
|
disabled={isUnlocking}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{isUnlocking ? "Unlocking..." : "Unlock"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import {
|
import { RefreshCw, Sparkles } from "lucide-react";
|
||||||
ImageIcon,
|
|
||||||
LockKeyhole,
|
|
||||||
RefreshCw,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import type { PrivateRoomMoment } from "@/data/dto/private-room";
|
|
||||||
import { AppBottomNav, MobileShell } from "@/app/_components/core";
|
import { AppBottomNav, MobileShell } from "@/app/_components/core";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
@@ -20,6 +13,11 @@ import {
|
|||||||
usePrivateRoomState,
|
usePrivateRoomState,
|
||||||
} from "@/stores/private-room";
|
} from "@/stores/private-room";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PrivateRoomPostCard,
|
||||||
|
StatusCard,
|
||||||
|
UnlockConfirmDialog,
|
||||||
|
} from "./components";
|
||||||
import styles from "./private-room-screen.module.css";
|
import styles from "./private-room-screen.module.css";
|
||||||
import {
|
import {
|
||||||
isPrivateRoomAuthRequired,
|
isPrivateRoomAuthRequired,
|
||||||
@@ -210,170 +208,3 @@ export function PrivateRoomScreen() {
|
|||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrivateRoomPostCard({
|
|
||||||
moment,
|
|
||||||
displayName,
|
|
||||||
avatarUrl,
|
|
||||||
index,
|
|
||||||
isUnlocking,
|
|
||||||
onUnlock,
|
|
||||||
}: {
|
|
||||||
moment: PrivateRoomMoment;
|
|
||||||
displayName: string;
|
|
||||||
avatarUrl: string;
|
|
||||||
index: number;
|
|
||||||
isUnlocking: boolean;
|
|
||||||
onUnlock: () => void;
|
|
||||||
}) {
|
|
||||||
const isLocked = moment.locked || moment.lockDetail.locked;
|
|
||||||
const imageUrls = moment.images
|
|
||||||
.filter((image) => !image.locked && image.url)
|
|
||||||
.map((image) => image.url as string);
|
|
||||||
const photoCount = moment.mediaCount || moment.images.length || 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
className={styles.postCard}
|
|
||||||
style={{ "--reveal-index": index } as CSSProperties}
|
|
||||||
>
|
|
||||||
<header className={styles.postHeader}>
|
|
||||||
<div className={styles.postAuthor}>
|
|
||||||
<Image
|
|
||||||
src={avatarUrl}
|
|
||||||
alt=""
|
|
||||||
width={38}
|
|
||||||
height={38}
|
|
||||||
className={styles.postAvatar}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p className={styles.authorName}>{moment.author.name || displayName}</p>
|
|
||||||
<p className={styles.authorSubline}>
|
|
||||||
{moment.title || "Private room drop"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<time className={styles.postTime}>
|
|
||||||
{moment.timeText || formatMomentTime(moment.publishedAt)}
|
|
||||||
</time>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{moment.text || moment.content ? (
|
|
||||||
<p className={styles.postText}>{moment.text || moment.content}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isLocked ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.lockedPreview}
|
|
||||||
disabled={isUnlocking}
|
|
||||||
onClick={onUnlock}
|
|
||||||
aria-label={`Unlock ${photoCount} private room photos from ${displayName}`}
|
|
||||||
>
|
|
||||||
<div className={styles.previewIcon}>
|
|
||||||
<LockKeyhole size={18} aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<div className={styles.previewText}>
|
|
||||||
<strong>{moment.unlockCost} credits</strong>
|
|
||||||
<span>{moment.textPreview || "Unlock to view"}</span>
|
|
||||||
<small>
|
|
||||||
<ImageIcon size={13} aria-hidden="true" />
|
|
||||||
{photoCount} {photoCount === 1 ? "photo" : "photos"}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className={styles.imageGrid}>
|
|
||||||
{imageUrls.map((url, imageIndex) => (
|
|
||||||
<Image
|
|
||||||
key={`${moment.momentId}-${imageIndex}`}
|
|
||||||
src={url}
|
|
||||||
alt=""
|
|
||||||
width={420}
|
|
||||||
height={420}
|
|
||||||
className={styles.unlockedImage}
|
|
||||||
sizes="(max-width: 540px) 86vw, 460px"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UnlockConfirmDialog({
|
|
||||||
moment,
|
|
||||||
isUnlocking,
|
|
||||||
errorMessage,
|
|
||||||
onCancel,
|
|
||||||
onConfirm,
|
|
||||||
}: {
|
|
||||||
moment: PrivateRoomMoment;
|
|
||||||
isUnlocking: boolean;
|
|
||||||
errorMessage: string | null;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={styles.dialogOverlay} role="alertdialog" aria-modal="true">
|
|
||||||
<div className={styles.dialog}>
|
|
||||||
<h2 className={styles.dialogTitle}>Unlock private moment?</h2>
|
|
||||||
<p className={styles.dialogCopy}>
|
|
||||||
This will use {moment.unlockCost} credits.
|
|
||||||
</p>
|
|
||||||
{errorMessage ? (
|
|
||||||
<p className={styles.dialogError}>{errorMessage}</p>
|
|
||||||
) : null}
|
|
||||||
<div className={styles.dialogActions}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.dialogSecondary}
|
|
||||||
disabled={isUnlocking}
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.dialogPrimary}
|
|
||||||
disabled={isUnlocking}
|
|
||||||
onClick={onConfirm}
|
|
||||||
>
|
|
||||||
{isUnlocking ? "Unlocking..." : "Unlock"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusCard({
|
|
||||||
message,
|
|
||||||
actionLabel,
|
|
||||||
onAction,
|
|
||||||
}: {
|
|
||||||
message: string;
|
|
||||||
actionLabel?: string;
|
|
||||||
onAction?: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={styles.statusCard}>
|
|
||||||
<p>{message}</p>
|
|
||||||
{actionLabel && onAction ? (
|
|
||||||
<button type="button" onClick={onAction}>
|
|
||||||
{actionLabel}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMomentTime(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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user