'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>
|
||||
);
|
||||
}
|
||||
@@ -23,12 +23,10 @@ function ProfileRow({
|
||||
badge?: string;
|
||||
onSelect?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-gray-100 bg-white p-3 text-left shadow-sm transition-shadow hover:shadow-md dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
const className =
|
||||
"flex w-full items-center gap-3 rounded-xl border border-gray-100 bg-white p-3 text-left shadow-sm transition-shadow hover:shadow-md dark:border-gray-800 dark:bg-gray-900";
|
||||
const content = (
|
||||
<>
|
||||
<img
|
||||
src={mediaUrl(profile.photo)}
|
||||
alt={profile.name}
|
||||
@@ -46,8 +44,16 @@ function ProfileRow({
|
||||
<span className="flex-shrink-0 rounded-full bg-gray-50 px-2 py-1 text-[10px] text-gray-400 dark:bg-gray-800 dark:text-gray-500">
|
||||
{timeLabel}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
if (onSelect) {
|
||||
return (
|
||||
<button type="button" onClick={onSelect} className={className}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <div className={className}>{content}</div>;
|
||||
}
|
||||
|
||||
export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfile }: LikesPanelProps) {
|
||||
@@ -93,15 +99,29 @@ export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfi
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{mutual.length ? (
|
||||
mutual.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
timeLabel={formatTime(profile.matchedAt)}
|
||||
badge={t("mutualBadge")}
|
||||
onSelect={() => onSelectProfile?.(profile)}
|
||||
/>
|
||||
))
|
||||
mutual.map((profile) =>
|
||||
profile.conversationId ? (
|
||||
<Link
|
||||
key={profile.id}
|
||||
href={`/chat/${profile.conversationId}`}
|
||||
className="block"
|
||||
>
|
||||
<ProfileRow
|
||||
profile={profile}
|
||||
timeLabel={formatTime(profile.matchedAt)}
|
||||
badge={t("mutualBadge")}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<ProfileRow
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
timeLabel={formatTime(profile.matchedAt)}
|
||||
badge={t("mutualBadge")}
|
||||
onSelect={() => onSelectProfile?.(profile)}
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-gray-200 p-4 text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
{t("noMutualYet")}
|
||||
@@ -110,7 +130,7 @@ export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfi
|
||||
</div>
|
||||
{mutual.length > 0 ? (
|
||||
<Link
|
||||
href="/messages"
|
||||
href="/chat"
|
||||
className="mt-3 inline-flex w-full items-center justify-center rounded-full bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-600"
|
||||
>
|
||||
{t("goMessages")}
|
||||
|
||||
@@ -6,11 +6,12 @@ import type { DatingProfile } from "../types";
|
||||
|
||||
interface MatchModalProps {
|
||||
profile: DatingProfile | null;
|
||||
conversationId?: string;
|
||||
onClose: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export default function MatchModal({ profile, onClose, t }: MatchModalProps) {
|
||||
export default function MatchModal({ profile, conversationId, onClose, t }: MatchModalProps) {
|
||||
if (!profile) return null;
|
||||
|
||||
return (
|
||||
@@ -33,7 +34,7 @@ export default function MatchModal({ profile, onClose, t }: MatchModalProps) {
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">📍 {profile.location}</p>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<Link
|
||||
href="/messages"
|
||||
href={conversationId ? `/chat/${conversationId}` : "/chat"}
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center justify-center rounded-full bg-[#ff4d4f] px-4 py-3 text-sm font-semibold text-white transition hover:bg-red-600"
|
||||
>
|
||||
|
||||
@@ -63,7 +63,10 @@ function DatingPageInner() {
|
||||
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
||||
const [showQuotaPrompt, setShowQuotaPrompt] = useState(false);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [matchCelebration, setMatchCelebration] = useState<DatingProfile | null>(null);
|
||||
const [matchCelebration, setMatchCelebration] = useState<{
|
||||
profile: DatingProfile;
|
||||
conversationId?: string;
|
||||
} | null>(null);
|
||||
const [mobilePanelOpen, setMobilePanelOpen] = useState(false);
|
||||
const [dragX, setDragX] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -193,7 +196,10 @@ function DatingPageInner() {
|
||||
const result = await saveSwipe(currentProfile.id, action, activeTab);
|
||||
setCanUndo(true);
|
||||
if (result.matched) {
|
||||
setMatchCelebration(currentProfile);
|
||||
setMatchCelebration({
|
||||
profile: currentProfile,
|
||||
conversationId: result.match?.conversationId,
|
||||
});
|
||||
refreshSidePanels();
|
||||
} else if (action === "like") {
|
||||
refreshSidePanels();
|
||||
@@ -467,7 +473,12 @@ function DatingPageInner() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MatchModal profile={matchCelebration} onClose={() => setMatchCelebration(null)} t={t} />
|
||||
<MatchModal
|
||||
profile={matchCelebration?.profile ?? null}
|
||||
conversationId={matchCelebration?.conversationId}
|
||||
onClose={() => setMatchCelebration(null)}
|
||||
t={t}
|
||||
/>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ProfileRecord {
|
||||
matchedAt?: string;
|
||||
intent?: MatchIntent;
|
||||
connectionId?: string;
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
export interface DatingProfile {
|
||||
@@ -36,10 +37,16 @@ export interface DatingProfile {
|
||||
likedAt?: string;
|
||||
matchedAt?: string;
|
||||
intent?: MatchIntent;
|
||||
connectionId?: string;
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
export interface SwipeResult {
|
||||
ok: boolean;
|
||||
matched: boolean;
|
||||
match?: Record<string, unknown> | null;
|
||||
match?: {
|
||||
id?: string;
|
||||
conversationId?: string;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export function mapProfile(record: ProfileRecord, index: number): DatingProfile
|
||||
likedAt: record.likedAt,
|
||||
matchedAt: record.matchedAt,
|
||||
intent: record.intent,
|
||||
connectionId: record.connectionId,
|
||||
conversationId: record.conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,12 @@ export default function MessagesPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/chat"
|
||||
className="rounded-xl border border-[#ff4d4f]/30 bg-[#fff5f5] px-4 py-2 text-sm font-medium text-[#ff4d4f] hover:bg-[#ffecec] dark:border-[#ff4d4f]/40 dark:bg-[#ff4d4f]/10 dark:hover:bg-[#ff4d4f]/20"
|
||||
>
|
||||
私信聊天
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllRead}
|
||||
|
||||
@@ -59,6 +59,7 @@ export default memo(function SiteMenu({
|
||||
titleKey: "community",
|
||||
items: [
|
||||
{ labelKey: "messages", href: "/messages", icon: "🔔", new: true },
|
||||
{ labelKey: "privateChat", href: "/chat", icon: "💬" },
|
||||
{ labelKey: "chat", href: "/join", icon: "💬" },
|
||||
{ labelKey: "hostMeetup", href: "/meetups/host", icon: "🎤", new: true },
|
||||
{ labelKey: "membersMap", href: "/map", icon: "🌐" },
|
||||
|
||||
81
app/lib/chat.ts
Normal file
81
app/lib/chat.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
export interface ChatPeer {
|
||||
id: string;
|
||||
name?: string;
|
||||
photo?: string;
|
||||
location?: string;
|
||||
citySlug?: string;
|
||||
}
|
||||
|
||||
export interface ConversationItem {
|
||||
id: string;
|
||||
type: string;
|
||||
intent?: string;
|
||||
pairKey?: string;
|
||||
matchConnectionId?: string;
|
||||
lastMessageAt?: string;
|
||||
lastMessagePreview?: string;
|
||||
unreadCount: number;
|
||||
peer?: ChatPeer | null;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversationId?: string;
|
||||
senderId: string;
|
||||
body: string;
|
||||
created?: string;
|
||||
mine: boolean;
|
||||
}
|
||||
|
||||
export async function fetchConversations(): Promise<{
|
||||
items: ConversationItem[];
|
||||
unread: number;
|
||||
}> {
|
||||
const data = await apiFetch<{ items: ConversationItem[]; unread: number }>("/api/conversations");
|
||||
return {
|
||||
items: data.items || [],
|
||||
unread: Number(data.unread || 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchConversation(conversationId: string): Promise<ConversationItem> {
|
||||
const data = await apiFetch<{ conversation: ConversationItem }>(`/api/conversations/${conversationId}`);
|
||||
return data.conversation;
|
||||
}
|
||||
|
||||
export async function fetchConversationByConnection(connectionId: string): Promise<ConversationItem> {
|
||||
const data = await apiFetch<{ conversation: ConversationItem }>(
|
||||
`/api/conversations/by-connection/${connectionId}`
|
||||
);
|
||||
return data.conversation;
|
||||
}
|
||||
|
||||
export async function fetchMessages(
|
||||
conversationId: string,
|
||||
options?: { limit?: number; before?: string }
|
||||
): Promise<ChatMessage[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
if (options?.before) params.set("before", options.before);
|
||||
const query = params.toString();
|
||||
const data = await apiFetch<{ items: ChatMessage[] }>(
|
||||
`/api/conversations/${conversationId}/messages${query ? `?${query}` : ""}`
|
||||
);
|
||||
return data.items || [];
|
||||
}
|
||||
|
||||
export async function sendChatMessage(conversationId: string, body: string): Promise<ChatMessage> {
|
||||
const data = await apiFetch<{ message: ChatMessage }>(`/api/conversations/${conversationId}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
return data.message;
|
||||
}
|
||||
|
||||
export async function markChatRead(conversationId: string): Promise<void> {
|
||||
await apiFetch(`/api/conversations/${conversationId}/read`, { method: "POST", body: JSON.stringify({}) });
|
||||
}
|
||||
Reference in New Issue
Block a user