This repository has been archived on 2026-08-27. You can view files and clone it, but cannot push or open issues or pull requests.
Files
RECS-WEB-MODLE-REACT/app/calendar/page.tsx
2026-08-27 20:15:24 +08:00

223 lines
13 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 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;
}