Files
dusuniot/src/routes/blog.$slug.tsx
T

302 lines
17 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.
/**
* # 路由 文章详情 /blog/$slug/
* 原站模板形态:左主栏(白卡正文 + 目录)+ 右侧栏(社交图标 / CONTACT US 表单 /
* IoT Gateways 产品分组折叠菜单)+ 底部 Related Posts;Elementor 文章通栏直出。
*/
import { createFileRoute, Link } from "@tanstack/react-router";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
import { InquiryForm } from "~/components/website/InquiryForm";
import { Button } from "~/components/ui/button";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
import { getPublishedContent, getMenuTree, listPosts, type MenuNode, type PostListItem } from "~/server/data";
import { PostHero } from "~/components/website/PostHero";
import { resolveImageVariants } from "~/utils/image-variant";
import { useT } from "~/i18n";
/** # 常量 文章页三列布局样式(single 模板取证:60/776/360,容器 1200,移动端堆叠) */
const POST_LAYOUT_CSS = `
.ds-postlayout { background: #fff; }
.ds-postlayout .ds-wrap { display: flex; align-items: flex-start; max-width: 1200px; margin: 0 auto; padding: 32px 0 40px; }
.ds-postlayout .ds-rail { width: 60px; flex: 0 0 60px; }
.ds-postlayout .ds-main { width: 776px; flex: 0 0 776px; min-width: 0; padding: 0 32px; }
.ds-postlayout .ds-side { width: 360px; flex: 0 0 360px; padding: 0 10px; display: flex; flex-direction: column; gap: 20px; }
@media (max-width: 1024px) {
.ds-postlayout .ds-wrap { flex-direction: column; }
.ds-postlayout .ds-rail { display: none; }
.ds-postlayout .ds-main, .ds-postlayout .ds-side { width: 100%; flex: 1 1 auto; }
}
`;
/** # 组件 分享竖栏(原站 elementor-share-buttons:facebook/twitter/linkedin 40px 方块) */
function ShareRail() {
const btns = [
{ label: "Share on facebook", bg: "#1868d5", path: "M504 256C504 119 393 8 256 8S8 119 8 256c0 123.78 90.69 226.38 209.25 245V327.69h-63V256h63v-54.64c0-62.15 37-96.48 93.67-96.48 27.14 0 55.52 4.84 55.52 4.84v61h-31.28c-30.8 0-40.41 19.12-40.41 38.73V256h68.78l-11 71.69h-57.78V501C413.31 482.38 504 379.78 504 256z" },
{ label: "Share on twitter", bg: "#1d9bf0", path: "M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z" },
{ label: "Share on linkedin", bg: "#0a66c2", path: "M504 256c0 137-111 248-248 248S8 393 8 256 119 8 256 8s248 111 248 248zm-379-56.5v193h64.7V199.5H125zm32.4-84.2c-11.3 0-20.4 9.2-20.4 20.4 0 11.3 9.2 20.4 20.4 20.4 11.3 0 20.4-9.2 20.4-20.4 0-11.3-9.1-20.4-20.4-20.4zM399 265.9c0-50.7-27-74.2-63-74.2-29 0-42 16-49.2 27.2v-23.4H222c.9 17.6 0 193 0 193h64.7v-107.8c0-5.8.4-11.6 2.1-15.7 4.7-11.6 15.3-23.7 33.1-23.7 23.4 0 32.7 17.8 32.7 43.9v103.3H419l-.1-122.6z" },
];
return (
<div className="flex flex-col gap-1.5 sticky top-[130px]">
{btns.map((b) => (
<a key={b.label} href="#" aria-label={b.label} onClick={(e) => e.preventDefault()} className="flex h-10 w-10 items-center justify-center rounded text-white" style={{ background: b.bg }}>
<svg viewBox="0 0 512 512" width="17" height="17" fill="currentColor" aria-hidden><path d={b.path} /></svg>
</a>
))}
</div>
);
}
/** # 组件 文章页侧栏(社交图标 + CONTACT US 表单 + IoT Gateways 折叠组) */
function PostSidebarWidget(props: { groups: MenuNode[]; productInterest: string }) {
const t = useT();
return (
<>
<div className="flex justify-center gap-3 rounded-lg bg-white p-4 shadow-[0_1px_6px_rgba(16,42,76,0.08)]">
<BrandIcon label="LinkedIn" bg="#0a66c2" path="M20.45 20.45h-3.55v-5.57c0-1.33-.03-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.27 2.37 4.27 5.45v6.29zM5.34 7.43a2.06 2.06 0 1 1 0-4.12 2.06 2.06 0 0 1 0 4.12zM7.12 20.45H3.56V9h3.56v11.45z" />
<BrandIcon label="YouTube" bg="#ff0000" path="M23.5 6.19a3.02 3.02 0 0 0-2.12-2.14C19.5 3.55 12 3.55 12 3.55s-7.5 0-9.38.5A3.02 3.02 0 0 0 .5 6.19C0 8.07 0 12 0 12s0 3.93.5 5.81a3.02 3.02 0 0 0 2.12 2.14c1.88.5 9.38.5 9.38.5s7.5 0 9.38-.5a3.02 3.02 0 0 0 2.12-2.14C24 15.93 24 12 24 12s0-3.93-.5-5.81zM9.55 15.57V8.43L15.82 12l-6.27 3.57z" />
<BrandIcon label="Facebook" bg="#1877f2" path="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.09 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.7 4.53-4.7 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.95.93-1.95 1.89v2.26h3.32l-.53 3.49h-2.79V24C19.61 23.09 24 18.1 24 12.07z" />
</div>
<div className="rounded-lg bg-white p-5 shadow-[0_1px_6px_rgba(16,42,76,0.08)]">
<InquiryForm formKey="contact-us-form-sidebar" productInterest={props.productInterest} title={t("blog_contact_title")} />
</div>
{props.groups.length > 0 ? (
<div className="rounded-lg bg-white p-5 shadow-[0_1px_6px_rgba(16,42,76,0.08)]">
<h3 className="mt-0 text-[20px] font-bold text-brand">{t("blog_gateways")}</h3>
<Accordion type="multiple" className="border-t">
{props.groups.map((g) => (
<AccordionItem key={g.id} value={`gw-${g.id}`}>
<AccordionTrigger className="text-[15px] font-medium">{g.title}</AccordionTrigger>
<AccordionContent>
<ul className="m-0 list-none p-0">
{g.children.map((c) => (
<li key={c.id} className="py-1">
<a href={c.url} className="text-[14px] text-muted-foreground hover:text-brand hover:underline">{c.title}</a>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
) : null}
</>
);
}
/** # 组件 社交品牌图标(lucide 新版已移除品牌图标,内联 SVG) */
function BrandIcon(props: { label: string; bg: string; path: string }) {
return (
<a href="#" aria-label={props.label} className="flex h-9 w-9 items-center justify-center rounded text-white" style={{ background: props.bg }}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden>
<path d={props.path} />
</svg>
</a>
);
}
export const Route = createFileRoute("/blog/$slug")({
loader: async ({ params }) => {
const [layout, post, latest, productMenu] = await Promise.all([
getLayoutData(),
getPublishedContent(params.slug, "post").catch(() => null),
listPosts(1, 6).catch(() => ({ items: [] as PostListItem[], total: 0, page: 1, pageSize: 6 })),
getMenuTree("menu-1").catch(() => [] as MenuNode[]),
]);
// 侧栏 IoT Gateways 折叠组 = 主菜单 Products 分支的子分组(By Protocols / Applications ...)
const gwGroups = productMenu.find((n) => n.title.trim().toLowerCase() === "products")?.children ?? [];
if (!post) return { layout, post: null, related: [] as PostListItem[], gwGroups };
// meta 收窄为具体 JSON 类型;image widget WP 尺寸变体解析
const meta = (post.meta ?? null) as Record<string, unknown> | null;
resolveImageVariants(meta?.elementor);
const terms = post.terms.map((t) => ({ name: t.name, slug: t.slug, taxonomy: t.taxonomy }));
const related = latest.items.filter((p) => p.slug !== params.slug).slice(0, 4);
return { layout, post: { ...post, meta, terms }, related, gwGroups };
},
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.post ? loaderData.post.seoTitle || `${loaderData.post.title} | Dusun IoT Blog` : "Post Not Found | Dusun IoT" },
{ name: "description", content: loaderData?.post?.seoDescription || loaderData?.post?.excerpt || "Blog post from Dusun IoT." },
],
}),
component: BlogPostPage,
});
/** # 组件 文章详情页 */
function BlogPostPage() {
const t = useT();
const data = Route.useLoaderData();
if (!data.post) {
return (
<Layout layout={data.layout}>
<div className="pt-[70px] pb-10 text-center">
<h1 className="m-0 text-[64px] text-brand">404</h1>
<p>{t("blog_not_found")}</p>
<Button asChild>
<Link to="/blog/">{t("blog_back")}</Link>
</Button>
</div>
</Layout>
);
}
const post = data.post;
const cats = post.terms.filter((t) => t.taxonomy === "category");
const elementor = extractElementor(post.meta);
// Elementor 文章(原站 single 模板):hero + 面包屑条 + 三列正文
// (cd804a1 取证 2026-11-03:分享栏 60px / 正文 776px / 侧栏 360px,容器 1200)
if (elementor) {
return (
<Layout layout={data.layout}>
<PostHero post={post} />
<style>{POST_LAYOUT_CSS}</style>
<div className="ds-postlayout">
<div className="ds-wrap">
<div className="ds-rail">
<ShareRail />
</div>
<div className="ds-main">
<ElementorRenderer doc={elementor} pageCss={(post.meta as Record<string, unknown> | null)?.page_css as string | undefined} />
</div>
<aside className="ds-side">
<PostSidebarWidget groups={data.gwGroups} productInterest={post.title} />
</aside>
</div>
</div>
</Layout>
);
}
// HTML 文章:从正文提取 h2/h3 生成目录(并注入锚点 id)
const toc = post.html ? buildToc(post.html) : null;
return (
<Layout layout={data.layout}>
<div className="site-container">
<Breadcrumb aria-label="Breadcrumb">
<BreadcrumbList>
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem><BreadcrumbLink to="/blog/">Blog</BreadcrumbLink></BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem><BreadcrumbPage>{post.title}</BreadcrumbPage></BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="grid grid-cols-1 items-start gap-7 min-[1080px]:grid-cols-[1fr_340px]">
{/* 主栏:白卡正文(目录 + 内容) */}
<article className="rounded-lg bg-white p-6 min-[760px]:p-9">
<h1 className="mt-0 text-[30px] leading-[1.35]">{post.title}</h1>
<div className="mb-5 text-[13px] text-muted-foreground">
{post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""}
{cats.length > 0 ? (
<>
{" · "}
{cats.map((c, i) => (
<span key={c.slug}>
{i > 0 ? ", " : ""}
{c.name}
</span>
))}
</>
) : null}
</div>
{post.featuredUrl ? (
<img src={post.featuredUrl} alt={post.featuredAlt || post.title} className="my-3 mb-5 w-full rounded-lg" />
) : null}
{toc && toc.items.length >= 3 ? (
<Accordion type="single" collapsible defaultValue="toc" className="mb-6 rounded-md border px-4">
<AccordionItem value="toc" className="border-b">
<AccordionTrigger>{t("blog_toc")}</AccordionTrigger>
<AccordionContent>
<ul className="m-0 list-none p-0">
{toc.items.map((it) => (
<li key={it.id} className={it.level === 3 ? "ml-5" : ""}>
<a href={`#${it.id}`} className="block py-1 text-[14px] text-brand hover:underline">
{it.text}
</a>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
) : null}
{elementor ? (
<ElementorRenderer doc={elementor} />
) : post.html ? (
<div className="content-html" dangerouslySetInnerHTML={{ __html: toc?.html || post.html }} />
) : (
<p className="text-muted-foreground">{post.excerpt || t("blog_importing")}</p>
)}
</article>
{/* 侧栏:社交图标 / CONTACT US 表单 / IoT Gateways 产品分组 */}
<aside className="space-y-5">
<div className="flex justify-center gap-3 rounded-lg bg-white p-4">
<BrandIcon label="LinkedIn" bg="#0a66c2" path="M20.45 20.45h-3.55v-5.57c0-1.33-.03-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.27 2.37 4.27 5.45v6.29zM5.34 7.43a2.06 2.06 0 1 1 0-4.12 2.06 2.06 0 0 1 0 4.12zM7.12 20.45H3.56V9h3.56v11.45z" />
<BrandIcon label="YouTube" bg="#ff0000" path="M23.5 6.19a3.02 3.02 0 0 0-2.12-2.14C19.5 3.55 12 3.55 12 3.55s-7.5 0-9.38.5A3.02 3.02 0 0 0 .5 6.19C0 8.07 0 12 0 12s0 3.93.5 5.81a3.02 3.02 0 0 0 2.12 2.14c1.88.5 9.38.5 9.38.5s7.5 0 9.38-.5a3.02 3.02 0 0 0 2.12-2.14C24 15.93 24 12 24 12s0-3.93-.5-5.81zM9.55 15.57V8.43L15.82 12l-6.27 3.57z" />
<BrandIcon label="Facebook" bg="#1877f2" path="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.09 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.7 4.53-4.7 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.95.93-1.95 1.89v2.26h3.32l-.53 3.49h-2.79V24C19.61 23.09 24 18.1 24 12.07z" />
</div>
<div className="rounded-lg bg-white p-5">
<InquiryForm formKey="contact-us-form-sidebar" productInterest={post.title} title={t("blog_contact_title")} />
</div>
{data.gwGroups.length > 0 ? (
<div className="rounded-lg bg-white p-5">
<h3 className="mt-0 text-[20px] font-bold text-brand">{t("blog_gateways")}</h3>
<Accordion type="multiple" className="border-t">
{data.gwGroups.map((g) => (
<AccordionItem key={g.id} value={`gw-${g.id}`}>
<AccordionTrigger className="text-[15px] font-medium">{g.title}</AccordionTrigger>
<AccordionContent>
<ul className="m-0 list-none p-0">
{g.children.map((c) => (
<li key={c.id} className="py-1">
<a href={c.url} className="text-[14px] text-muted-foreground hover:text-brand hover:underline">{c.title}</a>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
) : null}
</aside>
</div>
{/* 底部 Related Posts(原站:4 列卡片,灰渐变占位 + 标题) */}
{data.related.length > 0 ? (
<section className="my-12">
<h2 className="mb-6 text-center text-[26px]">{t("blog_related_posts")}</h2>
<div className="grid grid-cols-2 gap-5 min-[960px]:grid-cols-4">
{data.related.map((p) => (
<Link key={p.id} to="/blog/$slug/" params={{ slug: p.slug }} className="block">
<div className="mb-3 aspect-[4/3] overflow-hidden rounded-md bg-gradient-to-b from-[#9aa3ad] to-[#c9ced4]">
{p.imageUrl ? <img src={p.imageUrl} alt={p.title} loading="lazy" className="h-full w-full object-cover" /> : null}
</div>
<div className="text-[15px] leading-[1.45] font-medium hover:text-brand">{p.title}</div>
</Link>
))}
</div>
</section>
) : null}
</div>
</Layout>
);
}
/** # 函数 从 HTML 提取 h2/h3 生成目录条目,并给标题注入锚点 id
* 参数 html 文章正文 HTML
* 返回值 items 目录条目(level/text/id);html 注入 id 后的正文
*/
function buildToc(html: string): { items: { level: number; text: string; id: string }[]; html: string } {
const items: { level: number; text: string; id: string }[] = [];
let i = 0;
const out = html.replace(/<h([23])([^>]*)>([\s\S]*?)<\/h\1>/gi, (m, lvl: string, attrs: string, inner: string) => {
const text = inner.replace(/<[^>]*>/g, "").trim();
if (!text || items.length >= 40) return m;
const id = `toc-${i++}`;
items.push({ level: Number(lvl), text, id });
return `<h${lvl} id="${id}"${attrs}>${inner}</h${lvl}>`;
});
return { items, html: out };
}