Skip to content

Commit f2102b0

Browse files
committed
new map/calendar components (and corresponding docs). and also fixed polls
1 parent 9edeafd commit f2102b0

File tree

15 files changed

+653
-19
lines changed

15 files changed

+653
-19
lines changed

components/interactive/Calendar.js

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { useEffect, useMemo, useState } from 'react';
2+
3+
const MONTH_NAMES = [
4+
'January',
5+
'February',
6+
'March',
7+
'April',
8+
'May',
9+
'June',
10+
'July',
11+
'August',
12+
'September',
13+
'October',
14+
'November',
15+
'December',
16+
];
17+
18+
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
19+
20+
function normalizeEvents(events) {
21+
let source = events;
22+
23+
if (typeof source === 'string' && source.trim()) {
24+
try {
25+
const jsonLike = source
26+
.trim()
27+
.replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, '$1"$2":')
28+
.replace(/'/g, '"');
29+
30+
source = JSON.parse(jsonLike);
31+
} catch {
32+
source = [];
33+
}
34+
}
35+
36+
if (!Array.isArray(source)) {
37+
return [];
38+
}
39+
40+
return source
41+
.map((event) => {
42+
const date = new Date(event?.date);
43+
if (Number.isNaN(date.getTime())) {
44+
return null;
45+
}
46+
47+
return {
48+
date,
49+
dateKey: date.toISOString().slice(0, 10),
50+
title: String(event?.title || '').trim() || 'Untitled event',
51+
details: String(event?.details || event?.info || '').trim(),
52+
};
53+
})
54+
.filter(Boolean)
55+
.sort((a, b) => a.date - b.date);
56+
}
57+
58+
function getMonthGrid(year, month) {
59+
const firstDay = new Date(year, month - 1, 1);
60+
const daysInMonth = new Date(year, month, 0).getDate();
61+
const leadingEmpty = firstDay.getDay();
62+
63+
const cells = Array.from({ length: leadingEmpty }, () => null);
64+
for (let day = 1; day <= daysInMonth; day += 1) {
65+
cells.push(day);
66+
}
67+
68+
while (cells.length % 7 !== 0) {
69+
cells.push(null);
70+
}
71+
72+
return cells;
73+
}
74+
75+
function formatShortDate(date) {
76+
return date.toLocaleDateString(undefined, {
77+
month: 'short',
78+
day: 'numeric',
79+
year: 'numeric',
80+
});
81+
}
82+
83+
function toBoolean(value, fallback = true) {
84+
if (typeof value === 'boolean') {
85+
return value;
86+
}
87+
88+
if (typeof value === 'string') {
89+
const normalized = value.trim().toLowerCase();
90+
if (normalized === 'true') {
91+
return true;
92+
}
93+
if (normalized === 'false') {
94+
return false;
95+
}
96+
}
97+
98+
return fallback;
99+
}
100+
101+
function getInitialViewMonth({ startMonthSource, customYear, customMonth, events }) {
102+
const now = new Date();
103+
const normalizedSource = String(startMonthSource || 'today').trim().toLowerCase();
104+
105+
if (normalizedSource === 'first-event' && events.length > 0) {
106+
const firstEventDate = events[0].date;
107+
return {
108+
year: firstEventDate.getFullYear(),
109+
month: firstEventDate.getMonth() + 1,
110+
};
111+
}
112+
113+
if (normalizedSource === 'custom') {
114+
return {
115+
year: customYear,
116+
month: customMonth,
117+
};
118+
}
119+
120+
return {
121+
year: now.getFullYear(),
122+
month: now.getMonth() + 1,
123+
};
124+
}
125+
126+
function shiftMonth(year, month, delta) {
127+
const date = new Date(year, month - 1 + delta, 1);
128+
return {
129+
year: date.getFullYear(),
130+
month: date.getMonth() + 1,
131+
};
132+
}
133+
134+
export default function Calendar({
135+
mode = 'month',
136+
year = new Date().getFullYear(),
137+
month = new Date().getMonth() + 1,
138+
monthNavigation = true,
139+
startMonthSource = 'today',
140+
events = [],
141+
title = 'Calendar',
142+
className = '',
143+
}) {
144+
const safeYear = Number.isFinite(Number(year)) ? Number(year) : new Date().getFullYear();
145+
const safeMonth = Math.min(Math.max(Number(month) || 1, 1), 12);
146+
const allowMonthNavigation = toBoolean(monthNavigation, true);
147+
148+
const normalizedEvents = useMemo(() => normalizeEvents(events), [events]);
149+
150+
const initialView = useMemo(
151+
() => getInitialViewMonth({
152+
startMonthSource,
153+
customYear: safeYear,
154+
customMonth: safeMonth,
155+
events: normalizedEvents,
156+
}),
157+
[normalizedEvents, safeMonth, safeYear, startMonthSource]
158+
);
159+
160+
const [view, setView] = useState(initialView);
161+
162+
useEffect(() => {
163+
setView(initialView);
164+
}, [initialView]);
165+
166+
const viewYear = view.year;
167+
const viewMonth = view.month;
168+
169+
const eventMap = useMemo(() => {
170+
const map = new Map();
171+
172+
normalizedEvents.forEach((event) => {
173+
if (event.date.getFullYear() !== viewYear || event.date.getMonth() + 1 !== viewMonth) {
174+
return;
175+
}
176+
177+
if (!map.has(event.dateKey)) {
178+
map.set(event.dateKey, []);
179+
}
180+
map.get(event.dateKey).push(event);
181+
});
182+
183+
return map;
184+
}, [normalizedEvents, viewMonth, viewYear]);
185+
186+
if (mode === 'agenda') {
187+
return (
188+
<div className={`my-4 overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950/40 ${className}`}>
189+
<div className="border-b border-slate-200 px-4 py-3 dark:border-slate-800">
190+
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</p>
191+
</div>
192+
193+
<div className="divide-y divide-slate-200 dark:divide-slate-800">
194+
{normalizedEvents.length === 0 ? (
195+
<p className="px-4 py-4 text-sm text-slate-500 dark:text-slate-400">No appointments scheduled.</p>
196+
) : (
197+
normalizedEvents.map((event, index) => (
198+
<div key={`${event.dateKey}-${event.title}-${index}`} className="grid grid-cols-[130px_1fr] gap-4 px-4 py-3">
199+
<div className="text-sm font-semibold text-slate-700 dark:text-slate-200">{formatShortDate(event.date)}</div>
200+
<div>
201+
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">{event.title}</p>
202+
{event.details ? <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{event.details}</p> : null}
203+
</div>
204+
</div>
205+
))
206+
)}
207+
</div>
208+
</div>
209+
);
210+
}
211+
212+
const monthGrid = getMonthGrid(viewYear, viewMonth);
213+
214+
return (
215+
<div className={`my-4 overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950/40 ${className}`}>
216+
<div className="border-b border-slate-200 px-4 py-3 dark:border-slate-800">
217+
<div className="flex items-center justify-between gap-3">
218+
<div>
219+
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</p>
220+
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{MONTH_NAMES[viewMonth - 1]} {viewYear}</p>
221+
</div>
222+
223+
{allowMonthNavigation ? (
224+
<div className="flex items-center gap-2">
225+
<button
226+
type="button"
227+
onClick={() => setView((prev) => shiftMonth(prev.year, prev.month, -1))}
228+
className="rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
229+
>
230+
Prev
231+
</button>
232+
<button
233+
type="button"
234+
onClick={() => setView((prev) => shiftMonth(prev.year, prev.month, 1))}
235+
className="rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
236+
>
237+
Next
238+
</button>
239+
</div>
240+
) : null}
241+
</div>
242+
</div>
243+
244+
<div className="grid grid-cols-7 border-b border-slate-200 text-center text-xs font-semibold uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
245+
{WEEKDAYS.map((weekday) => (
246+
<div key={weekday} className="px-1 py-2">{weekday}</div>
247+
))}
248+
</div>
249+
250+
<div className="grid grid-cols-7">
251+
{monthGrid.map((day, index) => {
252+
const dateKey = day ? `${viewYear}-${String(viewMonth).padStart(2, '0')}-${String(day).padStart(2, '0')}` : '';
253+
const dayEvents = dateKey ? eventMap.get(dateKey) || [] : [];
254+
255+
return (
256+
<div
257+
key={`${dateKey || 'empty'}-${index}`}
258+
className="min-h-24 border-b border-r border-slate-200 px-2 py-1.5 last:border-r-0 dark:border-slate-800"
259+
>
260+
{day ? (
261+
<>
262+
<p className="text-xs font-semibold text-slate-700 dark:text-slate-200">{day}</p>
263+
{dayEvents.length > 0 ? (
264+
<div className="mt-1 space-y-1">
265+
{dayEvents.slice(0, 2).map((event, eventIndex) => (
266+
<p
267+
key={`${event.title}-${eventIndex}`}
268+
className="truncate rounded bg-blue-100 px-1.5 py-0.5 text-[11px] text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
269+
title={event.details || event.title}
270+
>
271+
{event.title}
272+
</p>
273+
))}
274+
{dayEvents.length > 2 ? (
275+
<p className="text-[11px] text-slate-500 dark:text-slate-400">+{dayEvents.length - 2} more</p>
276+
) : null}
277+
</div>
278+
) : null}
279+
</>
280+
) : null}
281+
</div>
282+
);
283+
})}
284+
</div>
285+
</div>
286+
);
287+
}

components/interactive/Map.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import dynamic from 'next/dynamic';
2+
3+
function toNumber(value, fallback) {
4+
const parsed = Number(value);
5+
return Number.isFinite(parsed) ? parsed : fallback;
6+
}
7+
8+
function clamp(value, min, max) {
9+
return Math.min(Math.max(value, min), max);
10+
}
11+
12+
const MapCanvas = dynamic(() => import('./MapCanvas'), {
13+
ssr: false,
14+
loading: () => <div className="w-full bg-slate-100 dark:bg-slate-900" style={{ height: '320px' }} />,
15+
});
16+
17+
export default function Map({
18+
lat = 40.7128,
19+
lng = -74.006,
20+
zoom = 12,
21+
height = 360,
22+
title = 'Location',
23+
caption = '',
24+
tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
25+
attribution = '&copy; OpenStreetMap contributors',
26+
markerLabel = 'Selected location',
27+
className = '',
28+
}) {
29+
const latitude = clamp(toNumber(lat, 40.7128), -85, 85);
30+
const longitude = clamp(toNumber(lng, -74.006), -180, 180);
31+
const zoomLevel = clamp(Math.round(toNumber(zoom, 12)), 1, 19);
32+
const frameHeight = Math.max(220, Math.round(toNumber(height, 360)));
33+
34+
const openStreetMapUrl = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=${zoomLevel}/${latitude}/${longitude}`;
35+
36+
return (
37+
<div className={`my-4 overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950/40 ${className}`}>
38+
<div className="border-b border-slate-200 px-4 py-3 dark:border-slate-800">
39+
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</p>
40+
{caption ? <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{caption}</p> : null}
41+
</div>
42+
43+
<MapCanvas
44+
latitude={latitude}
45+
longitude={longitude}
46+
zoomLevel={zoomLevel}
47+
frameHeight={frameHeight}
48+
tileUrl={tileUrl}
49+
attribution={attribution}
50+
markerLabel={markerLabel}
51+
/>
52+
53+
<div className="border-t border-slate-200 px-4 py-2 text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
54+
<a href={openStreetMapUrl} target="_blank" rel="noreferrer" className="underline underline-offset-2 hover:text-slate-700 dark:hover:text-slate-300">
55+
Open in OpenStreetMap
56+
</a>
57+
</div>
58+
</div>
59+
);
60+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { CircleMarker, MapContainer, Popup, TileLayer } from 'react-leaflet';
2+
3+
export default function MapCanvas({
4+
latitude,
5+
longitude,
6+
zoomLevel,
7+
frameHeight,
8+
tileUrl,
9+
attribution,
10+
markerLabel,
11+
}) {
12+
return (
13+
<MapContainer
14+
center={[latitude, longitude]}
15+
zoom={zoomLevel}
16+
scrollWheelZoom={false}
17+
style={{ height: `${frameHeight}px`, width: '100%' }}
18+
className="z-0"
19+
>
20+
<TileLayer url={tileUrl} attribution={attribution} />
21+
<CircleMarker center={[latitude, longitude]} radius={8} pathOptions={{ color: '#2563eb', fillColor: '#60a5fa', fillOpacity: 0.9 }}>
22+
<Popup>{markerLabel}</Popup>
23+
</CircleMarker>
24+
</MapContainer>
25+
);
26+
}

0 commit comments

Comments
 (0)