Files
gitlab-instance-0a899031_cn…/app/components/CityModal.tsx
2026-06-06 04:29:46 -05:00

596 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "@/i18n/navigation";
import { Link } from "@/i18n/navigation";
import { City } from "@/app/data/cities";
import { apiFetch } from "@/app/lib/api-client";
const CITY_COORDS: Record<string, [number, number]> = {
: [25.7, 100.2],
: [30.6, 104.1],
: [22.5, 114.1],
: [31.2, 121.5],
: [30.3, 120.2],
: [24.5, 118.1],
: [39.9, 116.4],
广: [23.1, 113.3],
: [29.6, 106.6],
: [25.0, 102.7],
: [18.3, 109.5],
: [26.9, 100.2],
西: [34.3, 108.9],
: [32.1, 118.8],
: [28.2, 112.9],
: [36.1, 120.4],
: [31.3, 120.6],
: [22.3, 113.6],
};
const CITY_IMAGES: Record<string, string> = {
: "https://images.unsplash.com/photo-1590856029826-c7a73142bbf1?w=1200",
: "https://images.unsplash.com/photo-1547981609-4b6bfe67ca0b?w=1200",
: "https://images.unsplash.com/photo-1518837695005-2083093ee35b?w=1200",
: "https://images.unsplash.com/photo-1542051841857-5f90071e7989?w=1200",
: "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200",
: "https://images.unsplash.com/photo-1590559899731-a382839e5549?w=1200",
: "https://images.unsplash.com/photo-1508804185872-d7badad00f7d?w=1200",
广: "https://images.unsplash.com/photo-1596422846543-75c6fc197f07?w=1200",
: "https://images.unsplash.com/photo-1563841930606-67e2cce48b78?w=1200",
: "https://images.unsplash.com/photo-1589553416260-f586c8f1514f?w=1200",
: "https://images.unsplash.com/photo-1590523277543-a94d2e4eb00b?w=1200",
: "https://images.unsplash.com/photo-1548013146-72479768bada?w=1200",
西: "https://images.unsplash.com/photo-1590559899731-a382839e5549?w=1200",
: "https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=1200",
: "https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=1200",
: "https://images.unsplash.com/photo-1552465011-b4e21bf6e79a?w=1200",
: "https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=1200",
: "https://images.unsplash.com/photo-1590523277543-a94d2e4eb00b?w=1200",
};
type ScoreLabel = "great" | "good" | "okay" | "bad";
function scoreToLabel(score: number): ScoreLabel {
if (score >= 4.2) return "great";
if (score >= 3.5) return "good";
if (score >= 2.5) return "okay";
return "bad";
}
interface CityModalProps {
city: City | null;
isOpen: boolean;
onClose: () => void;
}
interface CityDetailData {
detail?: Record<string, unknown> | null;
content?: Array<Record<string, unknown>>;
meetups?: Array<Record<string, unknown>>;
discussions?: Array<Record<string, unknown>>;
}
export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
const { t } = useTranslation("cityModal");
const [activeTab, setActiveTab] = useState("scores");
const [mounted, setMounted] = useState(false);
const [favorited, setFavorited] = useState(false);
const [detailData, setDetailData] = useState<CityDetailData | null>(null);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!city || !isOpen) return;
const slug = city.slug || city.nameEn?.toLowerCase() || city.name.toLowerCase();
apiFetch<CityDetailData & { ok: boolean }>(`/api/cities/${slug}`)
.then((data) => setDetailData(data))
.catch(() => setDetailData(null));
}, [city, isOpen]);
if (!isOpen || !city || !mounted) return null;
const coverImage = city.coverImage ?? CITY_IMAGES[city.name] ?? `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`;
const reviewsCount = city.reviewsCount ?? city.nomadsNow * 4 + ((city.id * 97) % 500);
const likedCount = city.likedCount ?? Math.floor(reviewsCount * 0.55);
const dislikedCount = city.dislikedCount ?? Math.floor(reviewsCount * 0.08);
const qualityOfLife = (city.qualityOfLife?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore);
const familyScore = (city.familyScore?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore - 0.2);
const communityScore = (city.communityScore?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore + 0.1);
const nomadPercent = city.nomadPercent ?? Math.min(5, Math.ceil((city.nomadsNow / 500) * 2));
const funLabel = (city.funLabel?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore);
const feelsLike = city.feelsLikeTemp ?? city.temperature + 2;
const acPercent = city.acPercent ?? Math.min(95, 60 + city.temperature);
const [lat, lng] = city.lat && city.lng ? [city.lat, city.lng] : (CITY_COORDS[city.name] ?? [30, 120]);
const region = t("region");
const internetInfo = getInternetInfo(city.internetSpeed, t);
const tempInfo = getTempInfo(city.temperature, feelsLike, t);
const costLabel = getCostLabel(city.costPerMonth, t);
const humidityLabel = getHumidityLabel(city.humidity, t);
const acLabel = getAcLabel(acPercent, t);
const tabs = [
{ id: "guide", labelKey: "tabs.guide" },
{ id: "pros", labelKey: "tabs.pros" },
{ id: "reviews", labelKey: "tabs.reviews" },
{ id: "cost", labelKey: "tabs.cost" },
{ id: "people", labelKey: "tabs.people" },
{ id: "chat", labelKey: "tabs.chat" },
{ id: "photos", labelKey: "tabs.photos" },
{ id: "weather", labelKey: "tabs.weather" },
{ id: "trends", labelKey: "tabs.trends" },
{ id: "demographics", labelKey: "tabs.demographics" },
{ id: "scores", labelKey: "tabs.scores" },
];
const scoresList = [
{ icon: "🚩", labelKey: "scores.nomadScore", value: `${Math.round(city.overallScore)}/5 (${t("scores.rank")} #${city.id})`, good: true },
{ icon: "❤️", labelKey: "scores.likedByMembers", value: `${likedCount} ${t("scores.likedIt")} ${dislikedCount} ${t("scores.dislikedIt")}`, good: null },
{ icon: "⭐", labelKey: "scores.qualityOfLife", value: t(`scores.scoreLabels.${qualityOfLife}`), good: qualityOfLife !== "bad" },
{ icon: "👨‍👩‍👧", labelKey: "scores.familyScore", value: t(`scores.scoreLabels.${familyScore}`), good: familyScore !== "bad" },
{ icon: "👥", labelKey: "scores.communityScore", value: t(`scores.scoreLabels.${communityScore}`), good: communityScore !== "bad" },
{ icon: "🏙️", labelKey: "scores.nomadDensity", value: `${nomadPercent}% ${t("scores.nomadHere")}`, good: true },
{ icon: "💰", labelKey: "scores.cost", value: `${costLabel}: ¥${city.costPerMonth.toLocaleString()}/${t("scores.perMonth")}`, good: city.costPerMonth <= 6000 },
{ icon: "📡", labelKey: "scores.internet", value: internetInfo.text, good: internetInfo.isGood },
{ icon: "😊", labelKey: "scores.fun", value: t(`scores.scoreLabels.${funLabel}`), good: funLabel !== "bad" },
{ icon: "🌡️", labelKey: "scores.temperature", value: tempInfo.text, good: tempInfo.isGood },
{ icon: "💧", labelKey: "scores.humidity", value: `${humidityLabel}: ${city.humidity}%`, good: true },
{ icon: "❄️", labelKey: "scores.acPercent", value: `${acLabel}: ${acPercent}%`, good: acPercent >= 70 },
];
const mapUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${lng - 0.2},${lat - 0.15},${lng + 0.2},${lat + 0.15}&layer=mapnik&marker=${lat},${lng}`;
const modalContent = (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-3 sm:p-4 md:p-6">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
<div
className="relative z-10 flex flex-col w-full max-w-5xl max-h-[90vh] rounded-2xl overflow-hidden shadow-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700"
onClick={(e) => e.stopPropagation()}
>
{/* Header with cover image */}
<div className="relative h-36 sm:h-44 md:h-52 shrink-0">
{typeof coverImage === "string" && coverImage.startsWith("http") ? (
<img
src={coverImage}
alt={city.name}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div
className="absolute inset-0 w-full h-full"
style={{ background: coverImage as string }}
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent" />
<button
onClick={onClose}
className="absolute right-3 top-3 sm:right-4 sm:top-4 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center text-white transition-colors"
aria-label={t("close")}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="absolute bottom-0 left-0 right-0 p-3 sm:p-4 flex flex-wrap items-end justify-between gap-2">
<div>
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-white drop-shadow-lg">
{city.name}
</h1>
<p className="text-white/90 text-sm mt-0.5">
{city.countryEmoji} {city.country}
</p>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<span className="text-white/90 text-xs sm:text-sm">
{reviewsCount.toLocaleString()} {t("reviews")}
</span>
<button
onClick={() => setFavorited(!favorited)}
className={`rounded-full px-3 py-1.5 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium transition-colors ${
favorited
? "bg-[#ff4d4f] text-white"
: "bg-white/20 text-white hover:bg-white/30"
}`}
>
{favorited ? t("favorited") : t("favorite")}
</button>
</div>
</div>
</div>
{/* Breadcrumb */}
<div className="shrink-0 px-3 sm:px-4 py-2 border-b border-gray-100 dark:border-gray-800">
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
{t("breadcrumbPrefix")} &gt; {region} &gt; {city.country} &gt; {city.name}
</p>
</div>
{/* Tabs */}
<div className="shrink-0 border-b border-gray-100 dark:border-gray-800 overflow-x-auto">
<div className="flex gap-1 min-w-max px-3 sm:px-4 py-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm font-medium whitespace-nowrap rounded-lg transition-colors ${
activeTab === tab.id
? "text-[#ff4d4f] border-b-2 border-[#ff4d4f] -mb-[2px]"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
}`}
>
{t(tab.labelKey)}
</button>
))}
</div>
</div>
{/* Content - scrollable */}
<div className="flex-1 overflow-y-auto p-3 sm:p-4 md:p-6">
{activeTab === "scores" ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
<div className="space-y-2 sm:space-y-3">
{scoresList.map((s) => (
<div
key={s.labelKey}
className="flex items-center gap-2 sm:gap-3 p-2 sm:p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50"
>
<span className="text-lg sm:text-xl w-7 sm:w-8 shrink-0">{s.icon}</span>
<span className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">
{t(s.labelKey)}
</span>
<span
className={`text-xs font-medium px-2 py-0.5 sm:px-2.5 sm:py-1 rounded-full shrink-0 ${
s.good === null
? "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
: s.good
? "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-400"
: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-400"
}`}
>
{String(s.value)}
</span>
</div>
))}
</div>
<div className="rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700 h-64 sm:h-72 lg:h-80">
<iframe
title={`${city.name} ${t("map")}`}
src={mapUrl}
className="w-full h-full"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
</div>
) : (
<CityTabContent activeTab={activeTab} city={city} data={detailData} />
)}
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}
function getInternetInfo(speed: number, t: (k: string) => string): { text: string; isGood: boolean } {
const avg = t("scores.avg");
if (speed >= 80) return { text: `${t("scores.fast")}: ${speed} Mbps (${avg})`, isGood: true };
if (speed >= 50) return { text: `${t("scores.medium")}: ${speed} Mbps (${avg})`, isGood: false };
return { text: `${t("scores.slow")}: ${speed} Mbps (${avg})`, isGood: false };
}
function getTempInfo(temp: number, feels: number, t: (k: string) => string): { text: string; isGood: boolean } {
const feelsLike = t("scores.feelsLike");
if (temp >= 32 || feels >= 38) return { text: `${t("scores.tooHot")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: false };
if (temp >= 25) return { text: `${t("scores.warm")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: true };
if (temp >= 15) return { text: `${t("scores.comfortable")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: true };
return { text: `${t("scores.cold")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: false };
}
function getCostLabel(cost: number, t: (k: string) => string): string {
if (cost <= 4000) return t("scores.affordable");
if (cost <= 7000) return t("scores.moderate");
return t("scores.expensive");
}
function getHumidityLabel(h: number, t: (k: string) => string): string {
if (h <= 60) return t("scores.comfy");
if (h <= 80) return t("scores.moderate");
return t("scores.humid");
}
function getAcLabel(pct: number, t: (k: string) => string): string {
if (pct >= 85) return t("scores.veryHigh");
if (pct >= 70) return t("scores.high");
return t("scores.normal");
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
function asArray(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? (value.filter((x) => x && typeof x === "object") as Array<Record<string, unknown>>) : [];
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function CityTabContent({
activeTab,
city,
data,
}: {
activeTab: string;
city: City;
data: CityDetailData | null;
}) {
const detail = asRecord(data?.detail);
const tab = asRecord(detail[activeTab]);
const content = data?.content || [];
const meetups = data?.meetups || [];
const discussions = data?.discussions || [];
if (activeTab === "guide") {
return (
<div className="grid gap-5 lg:grid-cols-[1fr_280px]">
<section className="space-y-4">
<Panel title="城市落地指南">
<p className="leading-7 text-gray-700 dark:text-gray-300">{String(tab.summary || city.description)}</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<InfoCard label="工作配置" value={String(tab.workSetup || `${city.internetSpeed}Mbps 网络,建议先短住实测。`)} />
<InfoCard label="适合人群" value={asStringArray(tab.bestFor).join(" · ") || city.tags.join(" · ")} />
</div>
</Panel>
<Panel title="30 天落地清单">
<CheckList items={asStringArray(tab.arrivalChecklist)} />
</Panel>
</section>
<section className="space-y-4">
<RelatedContent items={content} />
<Panel title="同城活动">
<div className="space-y-3">
{meetups.map((meetup) => (
<Link key={String(meetup.id)} href="/meetups" className="block rounded-xl bg-gray-50 p-3 text-sm text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
<span className="font-semibold">{String(meetup.venue || "活动地点待定")}</span>
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">{String(meetup.date || "")} · {String(meetup.rsvpCount || 0)} </span>
</Link>
))}
{meetups.length === 0 && (
<Link href="/meetups/host" className="block rounded-xl bg-gray-50 p-3 text-sm text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
</Link>
)}
</div>
</Panel>
</section>
</div>
);
}
if (activeTab === "pros") {
return (
<div className="grid gap-5 md:grid-cols-2">
<Panel title="优点">
<CheckList items={asStringArray(tab.pros)} tone="good" />
</Panel>
<Panel title="需要注意">
<CheckList items={asStringArray(tab.cons)} tone="warn" />
</Panel>
</div>
);
}
if (activeTab === "reviews") {
const items = asArray(tab.items);
return (
<div className="space-y-4">
{items.map((review, i) => (
<Panel key={i} title={`${review.author || "社区成员"} · ${review.role || "数字游民"}`}>
<p className="leading-7 text-gray-700 dark:text-gray-300">{String(review.text || "")}</p>
<p className="mt-3 text-sm text-[#ff4d4f]"> {String(review.score || city.overallScore)}/5</p>
</Panel>
))}
</div>
);
}
if (activeTab === "cost") {
const breakdown = asArray(tab.breakdown);
const total = Number(tab.monthlyTotal || city.costPerMonth);
return (
<Panel title={`月生活成本:¥${total.toLocaleString()}`}>
<div className="space-y-3">
{breakdown.map((item) => {
const amount = Number(item.amount || 0);
return (
<div key={String(item.label)}>
<div className="mb-1 flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>{String(item.label)}</span>
<span>¥{amount.toLocaleString()}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div className="h-full rounded-full bg-[#ff4d4f]" style={{ width: `${Math.min(100, (amount / total) * 100)}%` }} />
</div>
</div>
);
})}
</div>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">{String(tab.tip || "成本数据会随社区反馈持续校准。")}</p>
</Panel>
);
}
if (activeTab === "people") {
return (
<div className="grid gap-5 lg:grid-cols-2">
<Panel title={`${Number(tab.nomadsNow || city.nomadsNow).toLocaleString()} 位游民正在这里`}>
<div className="space-y-3">
{asArray(tab.personas).map((item) => (
<InfoCard key={String(item.name)} label={String(item.name)} value={`${String(item.percent)}%`} />
))}
</div>
</Panel>
<Panel title="相关访谈与工具">
<RelatedContent items={content} compact />
</Panel>
</div>
);
}
if (activeTab === "chat") {
return (
<div className="grid gap-5 lg:grid-cols-2">
<Panel title="同城频道">
<div className="space-y-3">
{asArray(tab.channels).map((channel) => (
<InfoCard key={String(channel.name)} label={String(channel.name)} value={`${String(channel.members)} 成员 · ${String(channel.status)}`} />
))}
</div>
<Link href="/join" className="mt-4 inline-flex rounded-full bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white"></Link>
</Panel>
<Panel title="同城讨论">
<div className="space-y-3">
{discussions.map((topic) => (
<Link key={String(topic.id)} href="/discuss" className="block rounded-xl bg-gray-50 p-3 text-sm text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
{String(topic.title)}
</Link>
))}
</div>
</Panel>
</div>
);
}
if (activeTab === "photos") {
const photos = asStringArray(tab.items);
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{photos.map((src) => (
<img key={src} src={src} alt={city.name} className="h-56 w-full rounded-2xl object-cover shadow-sm" />
))}
</div>
);
}
if (activeTab === "weather") {
return (
<Panel title="天气与季节">
<div className="grid gap-3 sm:grid-cols-3">
<InfoCard label="当前温度" value={`${String(tab.temperature || city.temperature)}°C`} />
<InfoCard label="湿度" value={`${String(tab.humidity || city.humidity)}%`} />
<InfoCard label="推荐月份" value={asStringArray(tab.bestMonths).join("、")} />
</div>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">{String(tab.note || "真实天气接口接入后会显示未来 7 天趋势。")}</p>
</Panel>
);
}
if (activeTab === "trends") {
return (
<Panel title="社群增长趋势">
<div className="flex h-48 items-end gap-3">
{asArray(tab.growth).map((point) => {
const value = Number(point.value || 0);
return (
<div key={String(point.month)} className="flex flex-1 flex-col items-center gap-2">
<div className="w-full rounded-t-lg bg-[#ff4d4f]" style={{ height: `${Math.max(18, Math.min(100, value / 12))}%` }} />
<span className="text-xs text-gray-400">{String(point.month)}</span>
</div>
);
})}
</div>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">{String(tab.insight || "趋势数据来自社区成员位置和活动报名聚合。")}</p>
</Panel>
);
}
if (activeTab === "demographics") {
return (
<div className="grid gap-5 md:grid-cols-2">
<Panel title="年龄结构">
{asArray(tab.age).map((item) => <InfoCard key={String(item.label)} label={String(item.label)} value={`${String(item.value)}%`} />)}
</Panel>
<Panel title="职业结构">
{asArray(tab.work).map((item) => <InfoCard key={String(item.label)} label={String(item.label)} value={`${String(item.value)}%`} />)}
</Panel>
</div>
);
}
return <RelatedContent items={content} />;
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-3 font-bold text-gray-900 dark:text-gray-100">{title}</h3>
{children}
</section>
);
}
function InfoCard({ label, value }: { label: string; value: string }) {
return (
<div className="mb-2 rounded-xl bg-gray-50 p-3 dark:bg-gray-800">
<p className="text-xs text-gray-400">{label}</p>
<p className="mt-1 text-sm font-medium text-gray-800 dark:text-gray-200">{value}</p>
</div>
);
}
function CheckList({ items, tone = "good" }: { items: string[]; tone?: "good" | "warn" }) {
const icon = tone === "good" ? "✓" : "!";
return (
<div className="space-y-2">
{items.map((item) => (
<div key={item} className="flex gap-2 rounded-xl bg-gray-50 p-3 text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<span className={tone === "good" ? "text-emerald-500" : "text-amber-500"}>{icon}</span>
<span>{item}</span>
</div>
))}
</div>
);
}
function RelatedContent({ items, compact = false }: { items: Array<Record<string, unknown>>; compact?: boolean }) {
const hrefFor = (item: Record<string, unknown>) => {
const type = String(item.type || "");
const slug = String(item.slug || "");
if (type === "ebook") return `/ebooks/${slug}`;
if (type === "video") return `/videos/${slug}`;
if (type === "app") return "/app";
return String(item.targetUrl || "https://digital.hackrobot.cn/zh");
};
return (
<Panel title="关联内容">
<div className="space-y-3">
{items.map((item) => {
const href = hrefFor(item);
const external = href.startsWith("http");
const body = (
<div className="flex gap-3 rounded-xl bg-gray-50 p-3 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700">
{!compact && <img src={String(item.coverImage || "")} alt="" className="h-14 w-20 rounded-lg object-cover" />}
<div className="min-w-0">
<p className="line-clamp-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{String(item.title)}</p>
<p className="mt-1 line-clamp-2 text-xs text-gray-500 dark:text-gray-400">{String(item.subtitle || "")}</p>
</div>
</div>
);
return external ? (
<a key={String(item.slug)} href={href} target="_blank" rel="noopener noreferrer">{body}</a>
) : (
<Link key={String(item.slug)} href={href}>{body}</Link>
);
})}
</div>
</Panel>
);
}