Update WBBR frontend template collection

This commit is contained in:
Codex
2026-08-27 20:15:24 +08:00
parent bd4d54a110
commit e444605e5d
85 changed files with 6322 additions and 115 deletions

222
app/calendar/page.tsx Normal file
View File

@@ -0,0 +1,222 @@
"use client";
import Link from "next/link";
import { CSSProperties, useMemo, useState } from "react";
import calendarData from "../data/seasonal-calendar.json";
import styles from "./SeasonalCalendar.module.css";
type CategoryId = "fruit" | "seafood" | "activity";
type Category = { id: CategoryId; name: string; icon: string; color: string; soft: string };
type CalendarEvent = {
id: string;
title: string;
category: CategoryId;
start: string;
end: string;
region: string;
bestTime: string;
summary: string;
tips: string;
};
type CalendarDay = { date: Date; key: string; inMonth: boolean };
const categories = calendarData.categories as Category[];
const events = calendarData.events as CalendarEvent[];
const eventPalette: Record<string, { color: string; soft: string }> = {
"fruit-peach": { color: "#df6548", soft: "#fff0e9" },
"fruit-grape": { color: "#9a6a31", soft: "#fbf1df" },
"fruit-fig": { color: "#a75278", soft: "#faedf3" },
"fruit-pear": { color: "#799536", soft: "#f0f6df" },
"seafood-crab": { color: "#267aa5", soft: "#e8f5fb" },
"seafood-urchin": { color: "#4f68b8", soft: "#edf1ff" },
"seafood-bass": { color: "#278b83", soft: "#e8f7f4" },
"seafood-shrimp": { color: "#46799c", soft: "#eaf3f8" },
"activity-buy": { color: "#6c57c8", soft: "#f0edff" },
"activity-menu": { color: "#a151b5", soft: "#f8ecfb" },
};
const eventImages: Record<string, string> = {
"fruit-peach": "/seasonal/honey-peach-pixel.png",
};
export default function SeasonalCalendarPage() {
const initial = parseDate(calendarData.defaultDate);
const [month, setMonth] = useState(() => new Date(initial.getFullYear(), initial.getMonth(), 1));
const [selectedDate, setSelectedDate] = useState(calendarData.defaultDate);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const [enabledCategories, setEnabledCategories] = useState<Set<CategoryId>>(() => new Set(categories.map((item) => item.id)));
const days = useMemo(() => makeCalendarDays(month), [month]);
const visibleEvents = useMemo(() => events.filter((event) => enabledCategories.has(event.category)), [enabledCategories]);
const selectedEvents = useMemo(() => visibleEvents.filter((event) => event.start <= selectedDate && event.end >= selectedDate), [selectedDate, visibleEvents]);
const selectedEvent = selectedEventId ? events.find((event) => event.id === selectedEventId) ?? null : null;
const monthEvents = useMemo(() => {
const first = formatDate(new Date(month.getFullYear(), month.getMonth(), 1));
const last = formatDate(new Date(month.getFullYear(), month.getMonth() + 1, 0));
return visibleEvents.filter((event) => event.start <= last && event.end >= first);
}, [month, visibleEvents]);
const changeMonth = (offset: number) => {
const next = new Date(month.getFullYear(), month.getMonth() + offset, 1);
setMonth(next);
setSelectedDate(formatDate(next));
};
const selectDay = (day: CalendarDay) => {
setSelectedDate(day.key);
if (!day.inMonth) setMonth(new Date(day.date.getFullYear(), day.date.getMonth(), 1));
};
const toggleCategory = (id: CategoryId) => setEnabledCategories((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
if (selectedEvent?.category === id) setSelectedEventId(null);
} else next.add(id);
return next;
});
const goToday = () => {
const today = new Date();
setMonth(new Date(today.getFullYear(), today.getMonth(), 1));
setSelectedDate(formatDate(today));
};
return (
<div className={styles.page}>
<header className={styles.topbar}>
<Link href="/" className={styles.brand}><span></span><div><strong>Seasonal Calendar</strong><small>WBBR STYLE TEMPLATE</small></div></Link>
<nav><Link href="/"></Link><a href="#calendar"></a><a href="#season-list"></a></nav>
<div className={styles.topStatus}><i /> JSON </div>
</header>
<main>
<section className={styles.hero}>
<div>
<p>SEASONAL SCHEDULE</p>
<h1>{calendarData.title}</h1>
<span>{calendarData.subtitle}</span>
</div>
<div className={styles.heroStats}>
<article><small></small><strong>{monthEvents.length}</strong><span></span></article>
<article><small></small><strong>{selectedEvents.length}</strong><span></span></article>
<article><small></small><strong>{enabledCategories.size}</strong><span></span></article>
</div>
</section>
<section className={styles.workspace} id="calendar">
<div className={styles.calendarPanel}>
<div className={styles.calendarToolbar}>
<div className={styles.monthControl}>
<button onClick={() => changeMonth(-1)} aria-label="上个月"></button>
<div><strong>{month.getFullYear()} {month.getMonth() + 1}</strong><small>{month.toLocaleDateString("zh-CN", { month: "long", year: "numeric" })}</small></div>
<button onClick={() => changeMonth(1)} aria-label="下个月"></button>
<button className={styles.todayButton} onClick={goToday}></button>
</div>
<div className={styles.categoryFilters}>
{categories.map((category) => <button key={category.id} className={enabledCategories.has(category.id) ? styles.filterActive : ""} onClick={() => toggleCategory(category.id)} style={{ "--category": category.color, "--category-soft": category.soft } as CSSProperties}><i />{category.name}<b>{events.filter((event) => event.category === category.id).length}</b></button>)}
</div>
</div>
{selectedEvent && <div className={styles.highlightNotice} style={eventStyle(selectedEvent)}>
<i />
<div><small></small><strong>{selectedEvent.title}</strong><span>{shortDate(selectedEvent.start)} {shortDate(selectedEvent.end)}</span></div>
<button onClick={() => setSelectedEventId(null)}> ×</button>
</div>}
<div className={styles.weekHeader}>{["周一", "周二", "周三", "周四", "周五", "周六", "周日"].map((day) => <span key={day}>{day}</span>)}</div>
<div className={styles.calendarGrid}>
{days.map((day) => {
const dayEvents = visibleEvents.filter((event) => event.start <= day.key && event.end >= day.key);
const isToday = day.key === formatDate(new Date());
const isEventRange = Boolean(selectedEvent && selectedEvent.start <= day.key && selectedEvent.end >= day.key);
return <div key={day.key} className={`${styles.dayCell} ${day.inMonth ? "" : styles.outsideMonth} ${selectedDate === day.key ? styles.selectedDay : ""} ${isEventRange ? styles.eventRangeDay : ""} ${isEventRange && selectedDate === day.key ? styles.selectedRangeDay : ""}`} onClick={() => selectDay(day)} style={isEventRange && selectedEvent ? eventStyle(selectedEvent) : undefined}>
<button className={`${styles.dayNumber} ${isToday ? styles.today : ""}`} onClick={(clickEvent) => { clickEvent.stopPropagation(); selectDay(day); }} aria-label={`${day.key}${dayEvents.length}项事件`}>{day.date.getDate()}</button>
<div className={styles.dayEvents}>
{dayEvents.slice(0, 3).map((event) => {
return <button type="button" key={event.id} className={`${event.start < day.key ? styles.continuesBefore : ""} ${event.end > day.key ? styles.continuesAfter : ""} ${selectedEventId === event.id ? styles.activeEventBar : ""}`} style={eventStyle(event)} onClick={(clickEvent) => { clickEvent.stopPropagation(); setSelectedDate(day.key); setSelectedEventId(event.id); }}>{event.title}</button>;
})}
{dayEvents.length > 3 && <b> {dayEvents.length - 3} </b>}
</div>
</div>;
})}
</div>
</div>
<aside className={styles.detailPanel}>
<header><div><small>SELECTED DATE</small><h2>{formatChineseDate(selectedDate)}</h2><span>{weekdayName(selectedDate)} · {selectedEvents.length} </span></div><b>{selectedDate.slice(8)}</b></header>
<div className={styles.detailList}>
{selectedEvents.map((event) => {
const category = categories.find((item) => item.id === event.category)!;
return <article key={event.id} className={selectedEventId === event.id ? styles.activeEventCard : ""} style={eventStyle(event)} role="button" tabIndex={0} onClick={() => setSelectedEventId(event.id)} onKeyDown={(keyEvent) => { if (keyEvent.key === "Enter" || keyEvent.key === " ") setSelectedEventId(event.id); }}>
{eventImages[event.id] && <img className={styles.eventImage} src={eventImages[event.id]} alt={`${event.title}像素插图`} />}
<div className={styles.eventHead}><span>{category.icon}</span><div><small>{category.name} · {event.region}</small><h3>{event.title}</h3></div></div>
<p>{event.summary}</p>
<dl><div><dt></dt><dd>{shortDate(event.start)} {shortDate(event.end)}</dd></div><div><dt></dt><dd>{event.bestTime}</dd></div></dl>
<footer><span></span>{event.tips}</footer>
</article>;
})}
{selectedEvents.length === 0 && <div className={styles.emptyDay}><span></span><h3></h3><p></p></div>}
</div>
</aside>
</section>
<section className={styles.seasonSection} id="season-list">
<div className={styles.sectionTitle}><div><p>SEASON OVERVIEW</p><h2></h2></div><span> {monthEvents.length} · </span></div>
<div className={styles.seasonGrid}>
{monthEvents.map((event) => {
const category = categories.find((item) => item.id === event.category)!;
return <button key={event.id} className={selectedEventId === event.id ? styles.activeSeasonEvent : ""} onClick={() => {
const start = parseDate(event.start);
const target = start < new Date(month.getFullYear(), month.getMonth(), 1) ? formatDate(new Date(month.getFullYear(), month.getMonth(), 1)) : event.start;
setSelectedDate(target);
setSelectedEventId(event.id);
document.getElementById("calendar")?.scrollIntoView({ behavior: "smooth" });
}} style={eventStyle(event)}>
{eventImages[event.id] ? <img className={styles.seasonImage} src={eventImages[event.id]} alt={`${event.title}像素插图`} /> : <span>{category.icon}</span>}<div><small>{category.name} · {event.region}</small><strong>{event.title}</strong><p>{event.summary}</p><time>{shortDate(event.start)} {shortDate(event.end)}</time></div><b>{selectedEventId === event.id ? "正在突出 ✓" : "突出日期 ↗"}</b>
</button>;
})}
</div>
</section>
</main>
</div>
);
}
function parseDate(value: string) {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}
function formatDate(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function makeCalendarDays(month: Date): CalendarDay[] {
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const mondayOffset = (first.getDay() + 6) % 7;
const start = new Date(first.getFullYear(), first.getMonth(), 1 - mondayOffset);
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + index);
return { date, key: formatDate(date), inMonth: date.getMonth() === month.getMonth() };
});
}
function formatChineseDate(value: string) {
const date = parseDate(value);
return `${date.getMonth() + 1}${date.getDate()}`;
}
function weekdayName(value: string) {
return ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"][parseDate(value).getDay()];
}
function shortDate(value: string) {
const [, month, day] = value.split("-");
return `${Number(month)}${Number(day)}`;
}
function eventStyle(event: CalendarEvent) {
const fallback = categories.find((item) => item.id === event.category)!;
const palette = eventPalette[event.id] ?? fallback;
return { "--event-color": palette.color, "--event-soft": palette.soft } as CSSProperties;
}