mirror of
https://github.com/KivotosRailway/website.git
synced 2026-11-03 19:07:56 +09:00
初步建设主页和新闻页面
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { NewsItem } from "@/data/news";
|
||||
import { applyLocale, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
|
||||
|
||||
export function HomePageContent({ news }: { news: NewsItem[] }) {
|
||||
const [locale, setLocale] = useState<Locale>("zh-CN");
|
||||
|
||||
useEffect(() => {
|
||||
const syncLocale = (nextLocale: Locale) => setLocale(nextLocale);
|
||||
|
||||
const currentLocale = getInitialLocale();
|
||||
syncLocale(currentLocale);
|
||||
applyLocale(currentLocale);
|
||||
|
||||
const onLocaleChange = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ locale?: Locale }>).detail;
|
||||
if (detail?.locale) {
|
||||
setLocale(detail.locale);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStorage = () => {
|
||||
const nextLocale = getInitialLocale();
|
||||
syncLocale(nextLocale);
|
||||
applyLocale(nextLocale);
|
||||
};
|
||||
|
||||
window.addEventListener("kr-locale-change", onLocaleChange);
|
||||
window.addEventListener("storage", handleStorage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("kr-locale-change", onLocaleChange);
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const t = getLocaleMessages(locale);
|
||||
|
||||
return (
|
||||
<main className="home-page">
|
||||
<section className="home-hero" aria-labelledby="hero-title">
|
||||
<div className="hero-scene" aria-hidden="true">
|
||||
<div className="hero-sky" />
|
||||
<div className="hero-city hero-city-left" />
|
||||
<div className="hero-city hero-city-right" />
|
||||
<div className="hero-overpass" />
|
||||
<div className="hero-track hero-track-one" />
|
||||
<div className="hero-track hero-track-two" />
|
||||
<div className="hero-train"><span /><span /><span /><span /></div>
|
||||
</div>
|
||||
<div className="hero-shade" />
|
||||
<div className="hero-content">
|
||||
<p className="hero-kicker">{t.home.kicker}</p>
|
||||
<h1 id="hero-title">{t.home.title[0]}<br />{t.home.title[1]}</h1>
|
||||
<p>{t.home.subtitle}</p>
|
||||
<a className="hero-link" href="#about">{t.home.cta} <span>→</span></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="home-news" id="about" aria-labelledby="latest-news-title">
|
||||
<div className="news-header">
|
||||
<h2 id="latest-news-title">{t.home.latest}</h2>
|
||||
<Link href="/news" className="news-more-link">
|
||||
{t.home.more} <span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="news-grid" aria-label={t.home.latest}>
|
||||
{news.map((post) => (
|
||||
<article key={post.id} className="news-card">
|
||||
<Link href={post.href} className="news-card-link" aria-label={post.title}>
|
||||
<div className="news-visual" style={{ backgroundImage: `url(${post.cover})` }} />
|
||||
<h3>{post.title}</h3>
|
||||
<time dateTime={post.date}>{post.date}</time>
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function PageBreadcrumb({ items }: { items: Array<{ label: string; href?: string }> }) {
|
||||
return (
|
||||
<nav className="page-breadcrumb" aria-label="页面位置">
|
||||
{items.map((item, index) => (
|
||||
<span className="page-breadcrumb-item" key={`${item.label}-${index}`}>
|
||||
{index > 0 && <span className="page-breadcrumb-separator" aria-hidden="true">›</span>}
|
||||
{item.href ? <Link href={item.href}>{item.label}</Link> : <span>{item.label}</span>}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { applyLocale, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
|
||||
|
||||
const footerColumnHrefs = [
|
||||
["/policy/user-agreement", "/policy/privacy-policy", "/policy/disclaimer", "/policy/resource-pack"],
|
||||
["/about", "/join-us"],
|
||||
["mailto:connect@kivotos.cc", "mailto:infringement@kivotos.cc"],
|
||||
] as const;
|
||||
|
||||
const footerLegalHrefs = ["https://wiki.kivotosrailway.com", "https://url.kivotos.cc/status", "/links"] as const;
|
||||
|
||||
function isIcpVisibleHost(hostname: string) {
|
||||
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname.endsWith(".kivotos.cc");
|
||||
}
|
||||
|
||||
export function SiteFooter() {
|
||||
const [locale, setLocale] = useState<Locale>("zh-CN");
|
||||
const [showIcp, setShowIcp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const syncLocale = (nextLocale: Locale) => setLocale(nextLocale);
|
||||
const handleLocaleChange = (event: Event) => {
|
||||
const nextLocale = (event as CustomEvent<{ locale?: Locale }>).detail?.locale ?? getInitialLocale();
|
||||
syncLocale(nextLocale);
|
||||
};
|
||||
|
||||
const handleStorage = () => {
|
||||
syncLocale(getInitialLocale());
|
||||
};
|
||||
|
||||
const currentLocale = getInitialLocale();
|
||||
syncLocale(currentLocale);
|
||||
applyLocale(currentLocale);
|
||||
setShowIcp(isIcpVisibleHost(window.location.hostname));
|
||||
window.addEventListener("kr-locale-change", handleLocaleChange);
|
||||
window.addEventListener("storage", handleStorage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("kr-locale-change", handleLocaleChange);
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const messages = getLocaleMessages(locale);
|
||||
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="site-footer-inner">
|
||||
<div className="site-footer-top">
|
||||
<div className="site-footer-socials" aria-label="socials">
|
||||
<span className="site-footer-title">{messages.footer.follow}</span>
|
||||
<div className="site-footer-icons">
|
||||
{messages.footer.social.map((item, index) => (
|
||||
<a key={`social-${index}`} href="#" aria-label={item} className="site-footer-icon" title={item}>
|
||||
{[
|
||||
"◌",
|
||||
"◍",
|
||||
"X",
|
||||
"in",
|
||||
"▶",
|
||||
][index] ?? "•"}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="site-footer-main">
|
||||
<div className="site-footer-columns">
|
||||
{messages.footer.columns.map((group, groupIndex) => (
|
||||
<div key={`column-${groupIndex}`} className="site-footer-column">
|
||||
<h3>{group.title}</h3>
|
||||
<ul>
|
||||
{group.links.map((link, linkIndex) => (
|
||||
<li key={`column-${groupIndex}-link-${linkIndex}`}><a href={footerColumnHrefs[groupIndex][linkIndex]}>{link}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="site-footer-brand-wrap">
|
||||
<div className="site-footer-brand" aria-label="Kivotos Railway">
|
||||
<Image
|
||||
src="/icon/kivotosrailway.svg"
|
||||
alt="Kivotos Railway"
|
||||
width={270}
|
||||
height={58}
|
||||
className="site-footer-brand-logo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="site-footer-meta">
|
||||
<div className="site-footer-legal-row">
|
||||
{messages.footer.legal.map((item, itemIndex) => (
|
||||
<a key={`legal-${itemIndex}`} href={footerLegalHrefs[itemIndex]}>{item}</a>
|
||||
))}
|
||||
</div>
|
||||
<p className="site-footer-description">{messages.footer.description}</p>
|
||||
<div className="site-footer-copyright-row">
|
||||
<span>{messages.footer.copyright}</span>
|
||||
{showIcp && <span className="site-footer-icp">{messages.footer.icp}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { applyLocale, getInitialLocale, getLocaleMessages, localeLabels, locales, localeShortLabels, type Locale } from "@/lib/i18n";
|
||||
|
||||
export function SiteHeader() {
|
||||
const [locale, setLocale] = useState<Locale>("zh-CN");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const syncLocale = (nextLocale: Locale) => setLocale(nextLocale);
|
||||
|
||||
const handleLocaleChange = (event: Event) => {
|
||||
const nextLocale = (event as CustomEvent<{ locale?: Locale }>).detail?.locale ?? getInitialLocale();
|
||||
syncLocale(nextLocale);
|
||||
};
|
||||
|
||||
const handleStorage = () => {
|
||||
syncLocale(getInitialLocale());
|
||||
};
|
||||
|
||||
const currentLocale = getInitialLocale();
|
||||
syncLocale(currentLocale);
|
||||
applyLocale(currentLocale);
|
||||
|
||||
window.addEventListener("kr-locale-change", handleLocaleChange);
|
||||
window.addEventListener("storage", handleStorage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("kr-locale-change", handleLocaleChange);
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const messages = getLocaleMessages(locale);
|
||||
|
||||
return (
|
||||
<header className="site-header">
|
||||
<Link className="site-logo" href="/" aria-label={`Kivotos Railway ${localeLabels[locale]}`}>
|
||||
<Image
|
||||
className="site-logo-image"
|
||||
src="/icon/kivotosrailway.svg"
|
||||
alt="Kivotos Railway"
|
||||
width={240}
|
||||
height={64}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
<nav className={`site-navigation${menuOpen ? " is-open" : ""}`} aria-label={localeLabels[locale]}>
|
||||
{messages.header.nav.map((label, index) => {
|
||||
const href = ["/", "/news", "/railway", "/about", "/members"][index] ?? "/";
|
||||
return (
|
||||
<Link key={href} href={href} onClick={() => setMenuOpen(false)}>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="site-header-actions">
|
||||
<div className="site-locale" aria-label="语言选择">
|
||||
{locales.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={item === locale ? "active" : ""}
|
||||
aria-pressed={item === locale}
|
||||
onClick={() => {
|
||||
setLocale(item);
|
||||
applyLocale(item);
|
||||
}}
|
||||
aria-label={localeLabels[item]}
|
||||
>
|
||||
{localeShortLabels[item]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="site-menu-button"
|
||||
type="button"
|
||||
aria-label="打开导航"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((current) => !current)}
|
||||
>
|
||||
<span /><span /><span />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type { NewsItem } from "@/data/news";
|
||||
|
||||
export function NewsList({ news }: { news: NewsItem[] }) {
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const categories = Array.from(new Set(news.map((item) => item.category).filter(Boolean)));
|
||||
const visibleNews = activeCategory
|
||||
? news.filter((item) => item.category === activeCategory)
|
||||
: news;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="news-toolbar" aria-label="新闻筛选和布局控制">
|
||||
<div className="news-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`news-filter-button${activeCategory === null ? " active" : ""}`}
|
||||
onClick={() => setActiveCategory(null)}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
className={`news-filter-button${activeCategory === category ? " active" : ""}`}
|
||||
onClick={() => setActiveCategory(category)}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="news-toolbar-right">
|
||||
<button type="button" className="news-view-toggle" aria-label="列表视图">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="news-list-grid">
|
||||
{visibleNews.map((item) => (
|
||||
<article key={item.id} className="news-list-card">
|
||||
<Link href={item.href} className="news-list-link">
|
||||
<div className="news-list-cover" style={{ backgroundImage: `url(${item.cover})` }} />
|
||||
<div className="news-list-meta">
|
||||
<span className="news-list-tag">{item.category}</span>
|
||||
<h3>{item.title}</h3>
|
||||
<time dateTime={item.date}>{item.date}</time>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function NewsShare({ title }: { title: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const getPageUrl = () => window.location.href;
|
||||
|
||||
const copyLink = async () => {
|
||||
await navigator.clipboard.writeText(getPageUrl());
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
|
||||
const shareOnX = () => {
|
||||
const url = encodeURIComponent(getPageUrl());
|
||||
const text = encodeURIComponent(title);
|
||||
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const shareOnQq = () => {
|
||||
const url = encodeURIComponent(getPageUrl());
|
||||
const text = encodeURIComponent(title);
|
||||
window.open(`https://connect.qq.com/widget/shareqq/index.html?url=${url}&title=${text}`, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="news-share" aria-labelledby="news-share-title">
|
||||
<h2 id="news-share-title">分享</h2>
|
||||
<div className="news-share-actions">
|
||||
<button type="button" className="news-share-button" onClick={copyLink} aria-label="复制链接">
|
||||
{copied ? "已复制" : "复制链接"}
|
||||
</button>
|
||||
<button type="button" className="news-share-button news-share-x" onClick={shareOnX} aria-label="分享到 X">X</button>
|
||||
<button type="button" className="news-share-button news-share-qq" onClick={shareOnQq} aria-label="分享到 QQ">QQ</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{ variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-white hover:bg-destructive/90", outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline" }, size: { default: "h-10 px-4 py-2", sm: "h-9 rounded-md px-3", lg: "h-11 rounded-md px-8", icon: "h-10 w-10" } }, defaultVariants: { variant: "default", size: "default" } },
|
||||
);
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, ...props }, ref) => (
|
||||
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
));
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
Reference in New Issue
Block a user