refactor: relocate components to app directory structure
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
background: var(--color-dark-background, #111);
|
||||
color: var(--color-text-primary, #fff);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-4, 16px) var(--spacing-5, 20px);
|
||||
border-bottom: 1px solid var(--color-border-subtle, rgba(255, 255, 255, 0.08));
|
||||
background: var(--color-header-background, rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-lg, 18px);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.area {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4, 16px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3, 12px);
|
||||
}
|
||||
|
||||
.aiDisclosure {
|
||||
align-self: center;
|
||||
background: var(--color-surface-muted, rgba(255, 255, 255, 0.06));
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--spacing-2, 8px) var(--spacing-4, 16px);
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
opacity: 0.75;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bubbleRowAi {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-1, 4px);
|
||||
}
|
||||
|
||||
.bubbleRowUser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-1, 4px);
|
||||
}
|
||||
|
||||
.bubbleAi {
|
||||
max-width: 75%;
|
||||
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
|
||||
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bubbleUser {
|
||||
max-width: 75%;
|
||||
background: var(--color-bubble-user, #4f46e5);
|
||||
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bubbleImage {
|
||||
max-width: 220px;
|
||||
max-height: 220px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bubbleTime {
|
||||
font-size: var(--font-size-xs, 11px);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.typingDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
margin: 0 2px;
|
||||
animation: typingBlink 1.2s infinite;
|
||||
}
|
||||
.typingDot:nth-child(2) { animation-delay: 0.15s; }
|
||||
.typingDot:nth-child(3) { animation-delay: 0.3s; }
|
||||
|
||||
@keyframes typingBlink {
|
||||
0%, 60%, 100% { opacity: 0.2; }
|
||||
30% { opacity: 1; }
|
||||
}
|
||||
|
||||
.inputBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2, 8px);
|
||||
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
|
||||
border-top: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.12));
|
||||
background: var(--color-input-bar-background, rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1 1 auto;
|
||||
height: 40px;
|
||||
padding: 0 var(--spacing-3, 12px);
|
||||
border: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.2));
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-accent, #4f46e5);
|
||||
}
|
||||
|
||||
.sendButton {
|
||||
height: 40px;
|
||||
padding: 0 var(--spacing-4, 16px);
|
||||
background: var(--color-accent, #4f46e5);
|
||||
color: #fff;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sendButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dialogActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-2, 8px);
|
||||
margin-top: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
.dialogTitle {
|
||||
margin: 0 0 var(--spacing-2, 8px);
|
||||
font-size: var(--font-size-lg, 18px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dialogPrimary,
|
||||
.dialogSecondary {
|
||||
height: 36px;
|
||||
padding: 0 var(--spacing-4, 16px);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialogPrimary {
|
||||
background: var(--color-accent, #4f46e5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dialogSecondary {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border-subtle, rgba(255, 255, 255, 0.2));
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.overlayCard {
|
||||
background: var(--color-surface, #1f1f1f);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
padding: var(--spacing-5, 20px);
|
||||
max-width: 320px;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||
import { Dialog } from "@/app/_components/core/dialog";
|
||||
import { useChatDispatch, useChatState } from "@/contexts/chat/chat-context";
|
||||
import { GuestChatQuota } from "@/contexts/chat/chat-types";
|
||||
import type { UiMessage } from "@/models/chat/ui-message";
|
||||
|
||||
import styles from "./chat-screen.module.css";
|
||||
|
||||
/**
|
||||
* 聊天屏幕
|
||||
*
|
||||
* 原始 Dart: lib/ui/chat/chat_screen.dart
|
||||
*
|
||||
* 组成(自上而下):
|
||||
* - ChatHeader(标题 + 菜单)
|
||||
* - ChatArea(消息列表 + AI 披露 + 日期分隔 + 加载动画)
|
||||
* - ChatInputBar(输入框 + 发送 / 语音 / 菜单)
|
||||
* - QuotaDialog(游客配额告警弹窗)
|
||||
* - PwaInstallOverlay / BrowserHintOverlay(占位)
|
||||
*/
|
||||
export function ChatScreen() {
|
||||
const state = useChatState();
|
||||
const dispatch = useChatDispatch();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [quotaDialogOpen, setQuotaDialogOpen] = useState(false);
|
||||
const [quotaExhausted, setQuotaExhausted] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [showPwaOverlay, setShowPwaOverlay] = useState(false);
|
||||
|
||||
// 初始化 + 切屏事件
|
||||
useEffect(() => {
|
||||
dispatch({ type: "ChatInit" });
|
||||
dispatch({ type: "ChatScreenVisible" });
|
||||
return () => {
|
||||
dispatch({ type: "ChatScreenInvisible" });
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// 配额触发监听
|
||||
const prevTriggerRef = useRef(state.quotaExceededTrigger);
|
||||
useEffect(() => {
|
||||
if (
|
||||
state.quotaExceededTrigger !== prevTriggerRef.current &&
|
||||
state.quotaExceededTrigger > 0
|
||||
) {
|
||||
setQuotaExhausted(true);
|
||||
setQuotaDialogOpen(true);
|
||||
}
|
||||
prevTriggerRef.current = state.quotaExceededTrigger;
|
||||
}, [state.quotaExceededTrigger]);
|
||||
|
||||
// 配额告警监听(剩余 = 5)
|
||||
const prevRemainingRef = useRef(state.guestRemainingQuota);
|
||||
useEffect(() => {
|
||||
if (
|
||||
state.isGuest &&
|
||||
state.guestRemainingQuota === GuestChatQuota.warningThreshold &&
|
||||
prevRemainingRef.current !== state.guestRemainingQuota
|
||||
) {
|
||||
setQuotaExhausted(false);
|
||||
setQuotaDialogOpen(true);
|
||||
}
|
||||
prevRemainingRef.current = state.guestRemainingQuota;
|
||||
}, [state.guestRemainingQuota, state.isGuest]);
|
||||
|
||||
// 滚动到底部
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [state.messages.length, state.isReplyingAI]);
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim()) return;
|
||||
dispatch({ type: "ChatSendMessage", content: input });
|
||||
setInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<MobileShell>
|
||||
<div className={styles.shell}>
|
||||
<ChatHeader isGuest={state.isGuest} />
|
||||
|
||||
<main ref={scrollRef} className={styles.area}>
|
||||
<AiDisclosure />
|
||||
{state.messages.map((m, i) => (
|
||||
<MessageBubble key={`m-${i}`} message={m} />
|
||||
))}
|
||||
{state.isReplyingAI && <LoadingBubble />}
|
||||
</main>
|
||||
|
||||
<ChatInputBar
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={quotaDialogOpen}
|
||||
onClose={() => setQuotaDialogOpen(false)}
|
||||
ariaLabel={quotaExhausted ? "Quota exhausted" : "Running low"}
|
||||
>
|
||||
<h2 className={styles.dialogTitle}>
|
||||
{quotaExhausted ? "Quota exhausted" : "Running low"}
|
||||
</h2>
|
||||
<p>
|
||||
{quotaExhausted
|
||||
? "You've used all your free messages. Please sign in to continue."
|
||||
: "You have a few messages left. Sign in to keep chatting."}
|
||||
</p>
|
||||
<div className={styles.dialogActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuotaDialogOpen(false)}
|
||||
className={styles.dialogSecondary}
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuotaDialogOpen(false);
|
||||
window.location.href = "/auth";
|
||||
}}
|
||||
className={styles.dialogPrimary}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{showPwaOverlay && (
|
||||
<PwaInstallOverlay onClose={() => setShowPwaOverlay(false)} />
|
||||
)}
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatHeader({ isGuest }: { isGuest: boolean }) {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.title}>cozsweet</h1>
|
||||
<span className={styles.subtitle}>
|
||||
{isGuest ? "Guest mode" : "Signed in"}
|
||||
</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function AiDisclosure() {
|
||||
return (
|
||||
<div className={styles.aiDisclosure}>
|
||||
You're chatting with an AI companion.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: UiMessage }) {
|
||||
const isAI = message.isFromAI;
|
||||
return (
|
||||
<div
|
||||
className={isAI ? styles.bubbleRowAi : styles.bubbleRowUser}
|
||||
aria-label={isAI ? "AI message" : "User message"}
|
||||
>
|
||||
{message.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={message.imageUrl}
|
||||
alt=""
|
||||
className={styles.bubbleImage}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={isAI ? styles.bubbleAi : styles.bubbleUser}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
)}
|
||||
<time className={styles.bubbleTime}>{message.date}</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBubble() {
|
||||
return (
|
||||
<div className={styles.bubbleRowAi} aria-label="AI typing">
|
||||
<div className={styles.bubbleAi}>
|
||||
<span className={styles.typingDot} />
|
||||
<span className={styles.typingDot} />
|
||||
<span className={styles.typingDot} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatInputBar({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<form className={styles.inputBar} onSubmit={onSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder="Say something…"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label="Message"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.sendButton}
|
||||
disabled={!value.trim()}
|
||||
aria-label="Send"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PwaInstallOverlay({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div className={styles.overlay} role="dialog" aria-label="Install app">
|
||||
<div className={styles.overlayCard}>
|
||||
<h2>Install cozsweet</h2>
|
||||
<p>Add to home screen for the best experience.</p>
|
||||
<button type="button" onClick={onClose}>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ChatScreen } from "@/components/chat/chat-screen";
|
||||
import { ChatScreen } from "@/app/chat/components/chat-screen";
|
||||
|
||||
export default function ChatPage() {
|
||||
return <ChatScreen />;
|
||||
|
||||
Reference in New Issue
Block a user