's'
This commit is contained in:
243
app/[locale]/chat/[id]/page.tsx
Normal file
243
app/[locale]/chat/[id]/page.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { useLocale, useTranslation } from "@/i18n/navigation";
|
||||
import { useParams } from "next/navigation";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { fetchAuthMe } from "@/app/lib/api-client";
|
||||
import {
|
||||
fetchConversation,
|
||||
fetchMessages,
|
||||
sendChatMessage,
|
||||
type ChatMessage,
|
||||
type ConversationItem,
|
||||
} from "@/app/lib/chat";
|
||||
import { mediaUrl } from "@/app/lib/media";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
|
||||
function formatMessageTime(value?: string, locale = "zh"): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleTimeString(locale === "zh" ? "zh-CN" : "en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ChatThreadPage() {
|
||||
const params = useParams();
|
||||
const conversationId = String(params?.id || "");
|
||||
const { t } = useTranslation("chat");
|
||||
const locale = useLocale();
|
||||
|
||||
const [conversation, setConversation] = useState<ConversationItem | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
const loadThread = useCallback(async () => {
|
||||
if (!conversationId) return;
|
||||
try {
|
||||
const [meta, items] = await Promise.all([
|
||||
fetchConversation(conversationId),
|
||||
fetchMessages(conversationId),
|
||||
]);
|
||||
setConversation(meta);
|
||||
setMessages(items);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("loadError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [conversationId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuthMe().then((data) => setIsLoggedIn(Boolean(data?.user?.id)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadThread();
|
||||
}, [loadThread]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId || !isLoggedIn) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
fetchMessages(conversationId)
|
||||
.then((items) => setMessages(items))
|
||||
.catch(() => undefined);
|
||||
}, POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [conversationId, isLoggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
const handleSend = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const body = draft.trim();
|
||||
if (!body || sending || !conversationId) return;
|
||||
setSending(true);
|
||||
setError("");
|
||||
try {
|
||||
const message = await sendChatMessage(conversationId, body);
|
||||
setMessages((prev) => [...prev, message]);
|
||||
setDraft("");
|
||||
setConversation((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
lastMessagePreview: body.replace(/\n/g, " ").slice(0, 180),
|
||||
lastMessageAt: message.created || prev.lastMessageAt,
|
||||
}
|
||||
: prev
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("sendError"));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const peerName = conversation?.peer?.name || (locale === "zh" ? "成员" : "Member");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-[#f6f7f9] dark:bg-gray-950">
|
||||
<header className="sticky top-0 z-20 border-b border-gray-200 bg-white/95 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95">
|
||||
<div className="mx-auto flex max-w-3xl items-center gap-3 px-4 py-3 sm:px-6">
|
||||
<Link
|
||||
href="/chat"
|
||||
className="rounded-full border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-900"
|
||||
>
|
||||
← {t("back")}
|
||||
</Link>
|
||||
{conversation?.peer ? (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<img
|
||||
src={mediaUrl(conversation.peer.photo)}
|
||||
alt={peerName}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-gray-900 dark:text-gray-100">{peerName}</p>
|
||||
{conversation.peer.location ? (
|
||||
<p className="truncate text-xs text-gray-500 dark:text-gray-400">📍 {conversation.peer.location}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="flex-1 font-semibold text-gray-900 dark:text-gray-100">{t("title")}</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-4 py-4 sm:px-6">
|
||||
{!isLoggedIn ? (
|
||||
<div className="flex flex-1 items-center justify-center rounded-2xl border border-dashed border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
|
||||
<div>
|
||||
<p className="text-gray-700 dark:text-gray-200">{t("loginRequired")}</p>
|
||||
<Link href="/join" className="mt-4 inline-flex text-sm font-semibold text-[#ff4d4f] hover:underline">
|
||||
{t("loginAction")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-2 border-[#ff4d4f] border-t-transparent" />
|
||||
</div>
|
||||
) : error && !messages.length ? (
|
||||
<div className="flex flex-1 items-center justify-center text-center">
|
||||
<div>
|
||||
<p className="text-gray-700 dark:text-gray-200">{error}</p>
|
||||
<Link href="/chat" className="mt-3 inline-flex text-sm font-semibold text-[#ff4d4f] hover:underline">
|
||||
{t("back")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex-1 space-y-3 overflow-y-auto rounded-2xl border border-gray-100 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
|
||||
style={{ minHeight: "50vh" }}
|
||||
>
|
||||
{!messages.length ? (
|
||||
<div className="flex h-full min-h-[40vh] flex-col items-center justify-center text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>{t("emptyThread")}</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${message.mine ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-2xl px-4 py-2.5 text-sm shadow-sm ${
|
||||
message.mine
|
||||
? "rounded-br-md bg-[#ff4d4f] text-white"
|
||||
: "rounded-bl-md bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100"
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words">{message.body}</p>
|
||||
<p
|
||||
className={`mt-1 text-[10px] ${
|
||||
message.mine ? "text-white/70" : "text-gray-400 dark:text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{formatMessageTime(message.created, locale)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn && !loading ? (
|
||||
<form onSubmit={handleSend} className="sticky bottom-0 mt-4 pb-4">
|
||||
{error && messages.length ? (
|
||||
<p className="mb-2 text-center text-xs text-red-500">{error}</p>
|
||||
) : null}
|
||||
<div className="flex gap-2 rounded-2xl border border-gray-200 bg-white p-2 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
rows={1}
|
||||
maxLength={2000}
|
||||
placeholder={t("inputPlaceholder")}
|
||||
className="max-h-32 min-h-[44px] flex-1 resize-none bg-transparent px-3 py-2.5 text-sm text-gray-900 outline-none dark:text-gray-100"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSend(event as unknown as FormEvent);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!draft.trim() || sending}
|
||||
className="self-end rounded-xl bg-[#ff4d4f] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{sending ? t("sending") : t("send")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
app/[locale]/chat/page.tsx
Normal file
159
app/[locale]/chat/page.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { useLocale, useTranslation } from "@/i18n/navigation";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { fetchAuthMe } from "@/app/lib/api-client";
|
||||
import { fetchConversations, type ConversationItem } from "@/app/lib/chat";
|
||||
import { mediaUrl } from "@/app/lib/media";
|
||||
|
||||
function formatTime(value?: string, locale = "zh"): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const diff = Date.now() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return locale === "zh" ? "刚刚" : "Now";
|
||||
if (minutes < 60) return locale === "zh" ? `${minutes} 分钟前` : `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return locale === "zh" ? `${hours} 小时前` : `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return locale === "zh" ? `${days} 天前` : `${days}d`;
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||
}
|
||||
|
||||
function ConversationRow({ item, locale }: { item: ConversationItem; locale: string }) {
|
||||
const peerName = item.peer?.name || (locale === "zh" ? "成员" : "Member");
|
||||
const preview = item.lastMessagePreview || (locale === "zh" ? "开始聊天吧" : "Say hello");
|
||||
const unread = Number(item.unreadCount || 0);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/chat/${item.id}`}
|
||||
className="flex items-center gap-3 rounded-2xl border border-gray-100 bg-white p-4 transition hover:border-[#ff4d4f]/30 hover:shadow-md dark:border-gray-800 dark:bg-gray-900 dark:hover:border-[#ff4d4f]/40"
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<img
|
||||
src={mediaUrl(item.peer?.photo)}
|
||||
alt={peerName}
|
||||
className="h-14 w-14 rounded-full object-cover shadow-sm"
|
||||
/>
|
||||
{unread > 0 ? (
|
||||
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-[#ff4d4f] px-1 text-[10px] font-bold text-white">
|
||||
{unread > 9 ? "9+" : unread}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate font-semibold text-gray-900 dark:text-gray-100">{peerName}</p>
|
||||
<span className="flex-shrink-0 text-[11px] text-gray-400">{formatTime(item.lastMessageAt, locale)}</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-sm text-gray-500 dark:text-gray-400">
|
||||
{item.peer?.location ? `📍 ${item.peer.location} · ` : ""}
|
||||
{preview}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatListPage() {
|
||||
const { t } = useTranslation("chat");
|
||||
const locale = useLocale();
|
||||
const [items, setItems] = useState<ConversationItem[]>([]);
|
||||
const [unread, setUnread] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
fetchConversations()
|
||||
.then((data) => {
|
||||
setItems(data.items);
|
||||
setUnread(data.unread);
|
||||
})
|
||||
.catch(() => {
|
||||
setItems([]);
|
||||
setUnread(0);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuthMe().then((data) => setIsLoggedIn(Boolean(data?.user?.id)));
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f6f7f9] dark:bg-gray-950">
|
||||
<main className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<header className="mb-6">
|
||||
<p className="text-sm font-semibold text-[#ff4d4f]">Direct Messages</p>
|
||||
<div className="mt-2 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-950 dark:text-gray-50">{t("title")}</h1>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{t("subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href="/messages"
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200"
|
||||
>
|
||||
{t("notifications")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/dating"
|
||||
className="rounded-xl bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white hover:bg-red-600"
|
||||
>
|
||||
{t("goMatching")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{unread > 0 ? (
|
||||
<p className="mt-3 text-sm font-medium text-[#ff4d4f]">
|
||||
{locale === "zh" ? `${unread} 条未读私信` : `${unread} unread message${unread === 1 ? "" : "s"}`}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{!isLoggedIn ? (
|
||||
<div className="rounded-2xl border border-dashed border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="text-gray-700 dark:text-gray-200">{t("loginRequired")}</p>
|
||||
<Link
|
||||
href="/join"
|
||||
className="mt-4 inline-flex rounded-full bg-[#ff4d4f] px-5 py-2.5 text-sm font-semibold text-white hover:bg-red-600"
|
||||
>
|
||||
{t("loginAction")}
|
||||
</Link>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((key) => (
|
||||
<div key={key} className="h-20 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length ? (
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<ConversationRow key={item.id} item={item} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="text-lg font-semibold text-gray-900 dark:text-gray-100">{t("emptyTitle")}</p>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{t("emptySubtitle")}</p>
|
||||
<Link
|
||||
href="/dating"
|
||||
className="mt-5 inline-flex rounded-full bg-[#ff4d4f] px-5 py-2.5 text-sm font-semibold text-white hover:bg-red-600"
|
||||
>
|
||||
{t("goMatching")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user