feat(chat): restore image viewer after payment

This commit is contained in:
2026-06-29 16:19:48 +08:00
parent 6273cd1bbe
commit 61796c732f
12 changed files with 297 additions and 63 deletions
+1 -5
View File
@@ -27,7 +27,6 @@ export interface ChatAreaProps {
isReplyingAI: boolean;
isGuest: boolean;
onUnlockPrivateMessage?: () => void;
onUnlockImagePaywall?: () => void;
onUnlockVoiceMessage?: () => void;
}
@@ -35,7 +34,6 @@ export function ChatArea({
messages,
isReplyingAI,
onUnlockPrivateMessage,
onUnlockImagePaywall,
onUnlockVoiceMessage,
}: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null);
@@ -59,7 +57,6 @@ export function ChatArea({
{renderMessagesWithDateHeaders(
messages,
onUnlockPrivateMessage,
onUnlockImagePaywall,
onUnlockVoiceMessage,
)}
@@ -72,7 +69,6 @@ export function ChatArea({
function renderMessagesWithDateHeaders(
messages: readonly UiMessage[],
onUnlockPrivateMessage?: () => void,
onUnlockImagePaywall?: () => void,
onUnlockVoiceMessage?: () => void,
) {
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
@@ -90,6 +86,7 @@ function renderMessagesWithDateHeaders(
) : (
<MessageBubble
key={item.key}
messageId={item.message.id}
content={item.message.content}
imageUrl={item.message.imageUrl}
imagePaywalled={item.message.imagePaywalled}
@@ -100,7 +97,6 @@ function renderMessagesWithDateHeaders(
lockedPrivate={item.message.lockedPrivate}
privateMessageHint={item.message.privateMessageHint}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockImagePaywall={onUnlockImagePaywall}
onUnlockVoiceMessage={onUnlockVoiceMessage}
/>
),
+41 -44
View File
@@ -4,73 +4,70 @@
*
* 支持:
* - base64 data URI 解码(`data:image/png;base64,...`
* - 点击 → 打开全屏查看器
* - 点击 → 跳转动态路由打开全屏查看器
* - 错误占位符(broken image icon
*/
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FullscreenImageViewer } from "./fullscreen-image-viewer";
import { ROUTE_BUILDERS } from "@/router/routes";
import styles from "./image-bubble.module.css";
export interface ImageBubbleProps {
messageId?: string;
imageUrl: string; // base64 data URI 或 URL
imagePaywalled?: boolean;
onUnlockImagePaywall?: () => void;
}
export function ImageBubble({
messageId,
imageUrl,
imagePaywalled = false,
onUnlockImagePaywall,
}: ImageBubbleProps) {
const [open, setOpen] = useState(false);
const router = useRouter();
const [error, setError] = useState(false);
const src = decodeBase64Image(imageUrl);
const canOpen = Boolean(messageId);
const openImage = () => {
if (!messageId) return;
router.push(ROUTE_BUILDERS.chatImage(messageId));
};
return (
<>
<div
className={`${styles.bubble} ${
imagePaywalled ? styles.paywalled : ""
}`}
onClick={() => setOpen(true)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
aria-label="Open image in fullscreen"
>
{error ? (
<div className={styles.errorFallback}>🖼</div>
) : (
<Image
className={styles.image}
src={src}
alt=""
width={240}
height={240}
onError={() => setError(true)}
/>
)}
{!error && imagePaywalled ? (
<div className={styles.paywallOverlay} aria-hidden="true" />
) : null}
</div>
{open && (
<FullscreenImageViewer
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
onUnlockImagePaywall={onUnlockImagePaywall}
onClose={() => setOpen(false)}
<div
className={`${styles.bubble} ${imagePaywalled ? styles.paywalled : ""}`}
onClick={openImage}
role={canOpen ? "button" : undefined}
tabIndex={canOpen ? 0 : undefined}
onKeyDown={(e) => {
if (!canOpen) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openImage();
}
}}
aria-label={canOpen ? "Open image in fullscreen" : undefined}
>
{error ? (
<div className={styles.errorFallback}>🖼</div>
) : (
<Image
className={styles.image}
src={src}
alt=""
width={240}
height={240}
onError={() => setError(true)}
/>
)}
</>
{!error && imagePaywalled ? (
<div className={styles.paywallOverlay} aria-hidden="true" />
) : null}
</div>
);
}
+4 -4
View File
@@ -15,6 +15,7 @@ import { MessageContent } from "./message-content";
import styles from "./chat-area.module.css";
export interface MessageBubbleProps {
messageId?: string;
content: string;
imageUrl?: string | null;
imagePaywalled?: boolean;
@@ -25,11 +26,11 @@ export interface MessageBubbleProps {
lockedPrivate?: boolean | null;
privateMessageHint?: string | null;
onUnlockPrivateMessage?: () => void;
onUnlockImagePaywall?: () => void;
onUnlockVoiceMessage?: () => void;
}
export function MessageBubble({
messageId,
content,
imageUrl,
imagePaywalled,
@@ -40,7 +41,6 @@ export function MessageBubble({
lockedPrivate,
privateMessageHint,
onUnlockPrivateMessage,
onUnlockImagePaywall,
onUnlockVoiceMessage,
}: MessageBubbleProps) {
const { avatarUrl } = useUserState();
@@ -55,13 +55,13 @@ export function MessageBubble({
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
audioUrl={audioUrl}
messageId={messageId}
isFromAI={true}
locked={locked}
lockReason={lockReason}
lockedPrivate={lockedPrivate}
privateMessageHint={privateMessageHint}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockImagePaywall={onUnlockImagePaywall}
onUnlockVoiceMessage={onUnlockVoiceMessage}
/>
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
@@ -77,13 +77,13 @@ export function MessageBubble({
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
audioUrl={audioUrl}
messageId={messageId}
isFromAI={false}
locked={locked}
lockReason={lockReason}
lockedPrivate={lockedPrivate}
privateMessageHint={privateMessageHint}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockImagePaywall={onUnlockImagePaywall}
onUnlockVoiceMessage={onUnlockVoiceMessage}
/>
<div style={{ width: 8 }} />
+3 -3
View File
@@ -9,13 +9,13 @@ export interface MessageContentProps {
imageUrl?: string | null;
imagePaywalled?: boolean;
audioUrl?: string | null;
messageId?: string;
isFromAI: boolean;
locked?: boolean | null;
lockReason?: string | null;
lockedPrivate?: boolean | null;
privateMessageHint?: string | null;
onUnlockPrivateMessage?: () => void;
onUnlockImagePaywall?: () => void;
onUnlockVoiceMessage?: () => void;
}
@@ -26,13 +26,13 @@ export function MessageContent({
imageUrl,
imagePaywalled,
audioUrl,
messageId,
isFromAI,
locked,
lockReason,
lockedPrivate,
privateMessageHint,
onUnlockPrivateMessage,
onUnlockImagePaywall,
onUnlockVoiceMessage,
}: MessageContentProps) {
const hasImage = imageUrl != null && imageUrl.length > 0;
@@ -66,9 +66,9 @@ export function MessageContent({
<>
{hasImage && imageUrl && (
<ImageBubble
messageId={messageId}
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
onUnlockImagePaywall={onUnlockImagePaywall}
/>
)}
{hasAudio && audioUrl ? (