Update WBBR frontend template collection
This commit is contained in:
14
app/calendar/SeasonalCalendar.module.css
Normal file
14
app/calendar/SeasonalCalendar.module.css
Normal file
File diff suppressed because one or more lines are too long
222
app/calendar/page.tsx
Normal file
222
app/calendar/page.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user