From 420303c1834b528a4550992628eb2938e48ac165 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:16:08 +0000 Subject: [PATCH 1/8] Initial plan From fc722bbc389a001675f8870e508326e970189fb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:34:02 +0000 Subject: [PATCH 2/8] feat: add activity detail page with map, comments, and edit button Co-authored-by: TechQuery <19969570+TechQuery@users.noreply.github.com> --- app/deadlines/[id]/page.tsx | 273 +++++++++++++++++++++++++++++++ components/CommentBox.tsx | 24 +++ components/EventCard.tsx | 8 + components/MapEmbed.tsx | 109 ++++++++++++ package.json | 1 + public/locales/en/common.json | 10 ++ public/locales/zh-CN/common.json | 10 ++ 7 files changed, 435 insertions(+) create mode 100644 app/deadlines/[id]/page.tsx create mode 100644 components/CommentBox.tsx create mode 100644 components/MapEmbed.tsx diff --git a/app/deadlines/[id]/page.tsx b/app/deadlines/[id]/page.tsx new file mode 100644 index 0000000..566a9cf --- /dev/null +++ b/app/deadlines/[id]/page.tsx @@ -0,0 +1,273 @@ +'use client'; + +import { CommentBox } from '@/components/CommentBox'; +import { MapEmbed } from '@/components/MapEmbed'; +import { TimelineItem } from '@/components/TimelineItem'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { DeadlineItem, EventData } from '@/lib/data'; +import { useEventStore } from '@/lib/store'; +import { formatTimezoneToUTC } from '@/lib/utils'; +import { + ArrowLeft, + Calendar, + Clock, + ExternalLink, + MapPin, + MessageSquare, + Pencil, +} from 'lucide-react'; +import { DateTime } from 'luxon'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +const DATA_EDIT_URL = + 'https://github.com/GoodAction-Hub/GoodAction-data/edit/main/activities.json'; + +interface FoundEvent { + item: DeadlineItem; + event: EventData; +} + +function findEvent(items: DeadlineItem[], id: string): FoundEvent | null { + for (const item of items) { + for (const event of item.events) { + if (event.id === id) return { item, event }; + } + } + return null; +} + +export default function EventDetailPage() { + const { t } = useTranslation('common'); + const params = useParams(); + const id = params.id as string; + + const { items, loading, fetchItems, displayTimezone } = useEventStore(); + const activeDotRef = useRef(null); + + useEffect(() => { + fetchItems(); + }, [fetchItems]); + + useEffect(() => { + useEventStore.setState({ mounted: true }); + }, []); + + const found = !loading && items.length > 0 ? findEvent(items, id) : null; + const dataLoaded = !loading && items.length > 0; + + if (loading || !dataLoaded) { + return ( +
+
+
+

{t('events.loading')}

+
+
+ ); + } + + if (!found) { + return ( +
+
+
🔍
+

+ {t('events.notFound')} +

+ + + +
+
+ ); + } + + const { item, event } = found; + const now = DateTime.now().setZone(displayTimezone); + + const upcomingDeadlines = event.timeline + .map((tl, index) => ({ + ...tl, + date: DateTime.fromISO(tl.deadline, { zone: event.timezone }), + index, + })) + .filter((tl) => tl.date.setZone(displayTimezone) > now) + .sort((a, b) => a.date.toMillis() - b.date.toMillis()); + + const nextDeadline = upcomingDeadlines[0]; + const upcomingIndexes = upcomingDeadlines.map((tl) => tl.index); + const ended = upcomingDeadlines.length === 0; + + const eventTimezoneUTC = formatTimezoneToUTC(event.timezone); + + const categoryStyle = { + conference: 'bg-gradient-to-r from-purple-500 to-purple-600 text-white', + competition: 'bg-gradient-to-r from-pink-500 to-pink-600 text-white', + activity: 'bg-gradient-to-r from-cyan-500 to-cyan-600 text-white', + }[item.category]; + + return ( +
+ {/* Background decorations */} +
+
+
+
+ +
+ {/* Back + Edit buttons */} +
+ + + + + + +
+ + {/* Main card */} + + + {/* Category badge + title */} +
+
+
+ {t(`filter.category_${item.category}`)} +
+ + {event.year} + + {ended && ( + + {t('events.ended')} + + )} +
+ +
+ +

+ {item.title} +

+ + +
+ +

+ {item.description} +

+
+ + {/* Tags */} + {item.tags.length > 0 && ( +
+ {item.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + + {/* Date / timezone / place */} +
+ {event.date && ( +
+ + {event.date} +
+ )} +
+ + {eventTimezoneUTC} +
+ {event.place && ( +
+ + {event.place} +
+ )} +
+ + {/* Timeline */} +
+
+ + {t('events.timeline')} +
+
+
+
+ {event.timeline.map((timelineEvent, index) => ( + + ))} +
+
+
+ + + + {/* Map section */} + {event.place && ( + + + + + {t('detail.location')} + + + + + + + )} + + {/* Comments section */} + + + + + {t('detail.comments')} + + + + + + +
+
+ ); +} diff --git a/components/CommentBox.tsx b/components/CommentBox.tsx new file mode 100644 index 0000000..958966d --- /dev/null +++ b/components/CommentBox.tsx @@ -0,0 +1,24 @@ +'use client'; + +import Giscus from '@giscus/react'; +import { useTranslation } from 'react-i18next'; + +export function CommentBox() { + const { i18n } = useTranslation('common'); + return ( + + ); +} + diff --git a/components/EventCard.tsx b/components/EventCard.tsx index 45f710b..63fcff5 100644 --- a/components/EventCard.tsx +++ b/components/EventCard.tsx @@ -13,6 +13,7 @@ import { Calendar, Clock, ExternalLink, + Info, MapPin, Star, } from 'lucide-react'; @@ -164,6 +165,13 @@ export function EventCard({ item, event }: EventCardProps) { onClick={() => toggleFavorite(cardId)} /> )} + + +
diff --git a/components/MapEmbed.tsx b/components/MapEmbed.tsx new file mode 100644 index 0000000..5a54c8f --- /dev/null +++ b/components/MapEmbed.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { ExternalLink, MapPin } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface GeoResult { + lat: string; + lon: string; +} + +interface MapEmbedProps { + address: string; +} + +/** Bounding box half-width in degrees (~5 km at the equator). */ +const BBOX_OFFSET = 0.05; + +export function MapEmbed({ address }: MapEmbedProps) { + const { t, i18n } = useTranslation('common'); + const [coords, setCoords] = useState(null); + const [loading, setLoading] = useState(!!address); + + useEffect(() => { + if (!address) { + return; + } + fetch( + `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`, + { + headers: { + 'Accept-Language': i18n.language ?? 'zh-CN', + 'User-Agent': 'GoodActionHub/1.0 (https://goodactionhub.org)', + }, + }, + ) + .then((r) => r.json()) + .then((results: GeoResult[]) => { + if (results[0]) setCoords(results[0]); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [address, i18n.language]); + + const mapUrl = coords + ? `https://www.openstreetmap.org/export/embed.html?bbox=${parseFloat(coords.lon) - BBOX_OFFSET},${parseFloat(coords.lat) - BBOX_OFFSET},${parseFloat(coords.lon) + BBOX_OFFSET},${parseFloat(coords.lat) + BBOX_OFFSET}&layer=mapnik&marker=${coords.lat},${coords.lon}` + : null; + + const amapUrl = `https://uri.amap.com/search?keyword=${encodeURIComponent(address)}`; + const osmUrl = `https://www.openstreetmap.org/search?query=${encodeURIComponent(address)}`; + + return ( +
+
+
+ + {address} +
+ + + {t('detail.openInAmap')} + + + + OpenStreetMap + +
+ + {loading && ( +
+
+
+ {t('detail.loadingMap')} +
+
+ )} + + {!loading && mapUrl && ( +