146 lines
6.0 KiB
TypeScript
146 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { RefreshCw, Coffee } from "lucide-react";
|
|
import SessionCoverImage from "./SessionCoverImage";
|
|
import AddressLink from "./AddressLink";
|
|
import { ACTIVITY_TYPES, SESSION_TAGS, MOCK_JOINERS_SESSION1, MOCK_JOINERS_SESSION2 } from "@/config/home.config";
|
|
import { mergeJoiners, getUniqueDbCount } from "@/app/lib/joiners";
|
|
import { useXhClickOpener } from "@/app/hooks/useXhClickOpener";
|
|
|
|
interface JoinStats {
|
|
count: number;
|
|
joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[];
|
|
}
|
|
|
|
function getMockForSession(sessionId: string) {
|
|
return sessionId === "session-1" ? MOCK_JOINERS_SESSION1 : MOCK_JOINERS_SESSION2;
|
|
}
|
|
|
|
function AvatarItem({ joiner }: { joiner: { name?: string; nickname?: string; avatar?: string; xiaohongshu_url?: string } }) {
|
|
const [imgError, setImgError] = useState(false);
|
|
const nickname = "name" in joiner ? joiner.name : joiner.nickname ?? "";
|
|
const avatar = joiner.avatar;
|
|
const xhUrl = joiner.xiaohongshu_url;
|
|
const showAvatar = avatar && avatar.length > 0 && !imgError;
|
|
const handleClick = useXhClickOpener(xhUrl);
|
|
|
|
return (
|
|
<span
|
|
className="relative w-7 h-7 rounded-full overflow-hidden border-2 border-white dark:border-stone-900 ring-1 ring-stone-200/50 dark:ring-stone-700/50 shrink-0 inline-block cursor-default"
|
|
{...(xhUrl && { "data-xiaohongshu-url": xhUrl })}
|
|
onClick={handleClick}
|
|
>
|
|
{showAvatar ? (
|
|
<Image src={avatar} alt="" fill sizes="28px" className="object-cover" unoptimized onError={() => setImgError(true)} />
|
|
) : (
|
|
<span className="flex items-center justify-center w-full h-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
|
|
{nickname.slice(0, 1) || "?"}
|
|
</span>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
type SessionTag = keyof typeof SESSION_TAGS;
|
|
|
|
interface SessionItem {
|
|
id: string;
|
|
type: "skill-exchange" | "side-hustle" | "loft" | "light-chat";
|
|
title: string;
|
|
date: string;
|
|
time: string;
|
|
place: string;
|
|
city: string;
|
|
coverImage?: string;
|
|
tags?: readonly SessionTag[];
|
|
}
|
|
|
|
export default function SessionCard({
|
|
session,
|
|
index,
|
|
stats,
|
|
}: {
|
|
session: SessionItem;
|
|
index: number;
|
|
stats: JoinStats | null;
|
|
}) {
|
|
|
|
return (
|
|
<div className="group flex flex-col sm:flex-row gap-3 sm:gap-4 p-4 sm:p-5 rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 hover:border-amber-200 dark:hover:border-amber-900/50 hover:shadow-lg hover:shadow-amber-900/5 transition-all duration-200 ease-out">
|
|
<div className="relative w-full sm:w-36 md:w-40 sm:min-w-[144px] md:min-w-[160px] shrink-0 aspect-[16/10] rounded-lg sm:rounded-xl overflow-hidden bg-stone-100 dark:bg-stone-800">
|
|
{session.coverImage ? (
|
|
<SessionCoverImage
|
|
src={session.coverImage}
|
|
alt={session.title}
|
|
type={session.type}
|
|
fill
|
|
sizes="(max-width: 640px) 100vw, (max-width: 768px) 144px, 160px"
|
|
className="object-cover"
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 flex items-center justify-center text-amber-500/70 dark:text-amber-400/50">
|
|
{session.type === "skill-exchange" ? <RefreshCw className="size-8" aria-hidden /> : <Coffee className="size-8" aria-hidden />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap gap-1 mb-1.5 min-h-[1.5rem]">
|
|
{session.tags?.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="inline-block text-[9px] sm:text-[10px] font-medium text-amber-600 dark:text-amber-400/90 bg-amber-50 dark:bg-amber-900/20 px-1.5 py-0.5 rounded"
|
|
>
|
|
{SESSION_TAGS[tag]}
|
|
</span>
|
|
))}
|
|
{!session.tags?.includes("light-topic") && (
|
|
<span className="inline-block text-[9px] sm:text-[10px] font-medium px-1.5 py-0.5 rounded opacity-0" aria-hidden>
|
|
{SESSION_TAGS["light-topic"]}
|
|
</span>
|
|
)}
|
|
{index === 0 ? (
|
|
<span className="inline-block text-[9px] sm:text-[10px] font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-1.5 py-0.5 rounded">
|
|
主推
|
|
</span>
|
|
) : (
|
|
<span className="inline-block text-[9px] sm:text-[10px] font-medium px-1.5 py-0.5 rounded opacity-0" aria-hidden>
|
|
主推
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[10px] sm:text-xs text-amber-600 dark:text-amber-400/90">
|
|
{ACTIVITY_TYPES[session.type]} · {session.city}
|
|
</span>
|
|
<h3 className="font-semibold text-stone-800 dark:text-stone-100 text-sm sm:text-base group-hover:text-amber-700 dark:group-hover:text-amber-400 mt-0.5">
|
|
{session.title}
|
|
</h3>
|
|
<p className="text-xs sm:text-sm text-stone-500 dark:text-stone-400 mt-0.5">
|
|
{session.date} {session.time}
|
|
</p>
|
|
<p className="text-xs sm:text-sm text-stone-500 dark:text-stone-400 mt-0.5">
|
|
<AddressLink place={session.place} city={session.city} />
|
|
</p>
|
|
<Link
|
|
href={`/join?session=${encodeURIComponent(session.id)}`}
|
|
className="inline-flex items-center gap-1 mt-2 sm:mt-3 text-amber-600 dark:text-amber-400 text-xs sm:text-sm font-medium hover:text-amber-700 dark:hover:text-amber-300 transition-colors"
|
|
>
|
|
报名这场 →
|
|
</Link>
|
|
<div className="mt-3 flex items-center gap-2 min-w-0 overflow-hidden">
|
|
<div className="flex -space-x-2 shrink-0">
|
|
{mergeJoiners(getMockForSession(session.id), stats?.joiners).map((j, i) => (
|
|
<AvatarItem key={`${i}-${(j.nickname || j.name) || i}`} joiner={j} />
|
|
))}
|
|
</div>
|
|
<span className="text-xs text-stone-500 dark:text-stone-400">
|
|
{3 + getUniqueDbCount(stats?.joiners)} 人已报名
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|