This commit is contained in:
eric
2026-06-08 03:06:12 -05:00
parent 60dfe538b0
commit ebfec37e47
34 changed files with 1753 additions and 31 deletions

View 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
View 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>
);
}

View File

@@ -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")}

View File

@@ -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"
>

View File

@@ -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>
);

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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}

View File

@@ -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
View 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({}) });
}