From e4bf34b39c40ea98a86e43589801674592760ea4 Mon Sep 17 00:00:00 2001 From: go165 <196723798+go165@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:32:40 +0800 Subject: [PATCH] feat: server render list pages with URL pagination --- app/activities/page.tsx | 314 +++++++++----------- app/api/data/route.ts | 16 +- app/restaurants/page.tsx | 339 ++++++++-------------- app/tutoring/page.tsx | 136 +++++---- components/FavoriteEventsFilter.tsx | 55 ++++ components/Pagination.tsx | 51 ++++ components/RestaurantNavigationButton.tsx | 103 +++++++ lib/activities.ts | 16 + 8 files changed, 570 insertions(+), 460 deletions(-) create mode 100644 components/FavoriteEventsFilter.tsx create mode 100644 components/Pagination.tsx create mode 100644 components/RestaurantNavigationButton.tsx diff --git a/app/activities/page.tsx b/app/activities/page.tsx index e7b5125..93ce9f1 100644 --- a/app/activities/page.tsx +++ b/app/activities/page.tsx @@ -1,188 +1,114 @@ -'use client'; - import { EventCard } from '@/components/EventCard'; -import { FilterBar } from '@/components/FilterBar'; +import { + FavoriteEventItem, + FavoriteEventsToggle, +} from '@/components/FavoriteEventsFilter'; import { GitCodeIcon } from '@/components/icons/GitCodeIcon'; import { GitHubIcon } from '@/components/icons/GitHubIcon'; - +import { Pagination } from '@/components/Pagination'; +import { TimezoneSelector } from '@/components/TimezoneSelector'; +import { fetchActivities } from '@/lib/activities'; import { DeadlineItem, EventData } from '@/lib/data'; -import { useEventStore } from '@/lib/store'; -import Fuse from 'fuse.js'; - import { DateTime } from 'luxon'; +import { Search } from 'lucide-react'; import Link from 'next/link'; -import { useEffect, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; + +const PAGE_SIZE = 10; + +interface ActivitiesPageProps { + searchParams?: Promise>; +} interface FlatEvent { item: DeadlineItem; event: EventData; nextDeadline: DateTime; timeRemaining: number; + searchableText: string; } -export default function Home() { - const { - items, - loading, - fetchItems, - selectedCategory, - selectedTags, - selectedLocations, - searchQuery, - favorites, - showOnlyFavorites, - } = useEventStore(); - - useEffect(() => { - fetchItems(); - }, [fetchItems]); - - const { t } = useTranslation(); - - const flatEvents: FlatEvent[] = useMemo( - () => - items.flatMap((item) => - item.events.map((event) => { - const now = DateTime.now().setZone('Asia/Shanghai'); - const upcomingDeadlines = event.timeline - .map((t) => DateTime.fromISO(t.deadline, { zone: event.timezone })) - .filter((d) => d > now) - .sort((a, b) => a.toMillis() - b.toMillis()); - - const nextDeadline = - upcomingDeadlines[0] || - DateTime.fromISO( - event.timeline[event.timeline.length - 1].deadline, - { zone: event.timezone }, - ); - const timeRemaining = nextDeadline.toMillis() - now.toMillis(); - - return { item, event, nextDeadline, timeRemaining }; - }), - ), - [items], - ); - - // 为每个事件添加搜索用的日期字段 - const eventsWithSearchDates = useMemo(() => { - return flatEvents.map((flatEvent) => ({ - ...flatEvent, - searchableDate: flatEvent.nextDeadline.toFormat('yyyy-MM-dd'), - searchableMonth: flatEvent.nextDeadline.toFormat('MM'), - searchableYear: flatEvent.nextDeadline.toFormat('yyyy'), - })); - }, [flatEvents]); - - const filteredEvents = useMemo(() => { - let filtered = eventsWithSearchDates; - - // 分类过滤 - if (selectedCategory) { - filtered = filtered.filter( - (flatEvent) => flatEvent.item.category === selectedCategory, - ); - } - - // 标签过滤 - if (selectedTags.length > 0) { - filtered = filtered.filter((flatEvent) => - selectedTags.some((tag) => flatEvent.item.tags?.includes(tag)), - ); - } - - // 地点过滤 - if (selectedLocations.length > 0) { - filtered = filtered.filter((flatEvent) => - selectedLocations.includes(flatEvent.event.place), - ); - } - - // 收藏过滤 - if (showOnlyFavorites) { - console.log('Filtering favorites:', { - favorites, - showOnlyFavorites, - totalEvents: filtered.length, - }); - filtered = filtered.filter((flatEvent) => { - const eventId = `${flatEvent.event.id}`; - const isFavorited = favorites.includes(eventId); - console.log( - `Event ${eventId}: ${isFavorited ? 'favorited' : 'not favorited'}`, - ); - return isFavorited; - }); - console.log('Filtered favorites result:', filtered.length); - } - - // 搜索过滤 - if (searchQuery.trim()) { - const fuse = new Fuse(filtered, { - keys: [ - { name: 'item.title', weight: 0.4 }, - { name: 'item.tags', weight: 0.3 }, - { name: 'event.place', weight: 0.2 }, - { name: 'searchableDate', weight: 0.1 }, - { name: 'searchableMonth', weight: 0.1 }, - { name: 'searchableYear', weight: 0.1 }, - ], - threshold: 0.3, - includeScore: true, - }); - - const results = fuse.search(searchQuery); - filtered = results.map((result) => result.item); - } - - // 排序逻辑:未结束的活动按 timeRemaining 升序,已结束的活动放在最后 - return filtered.sort((a, b) => { +const categories = [ + { value: '', label: '全部' }, + { value: 'conference', label: '会议' }, + { value: 'competition', label: '竞赛' }, + { value: 'activity', label: '活动' }, +]; + +const single = (value: string | string[] | undefined) => + Array.isArray(value) ? value[0] : value; + +const pageNumber = (value: string | undefined) => { + const page = Number.parseInt(value || '1', 10); + return Number.isFinite(page) && page > 0 ? page : 1; +}; + +function flattenEvents(items: DeadlineItem[]): FlatEvent[] { + const now = DateTime.now().setZone('Asia/Shanghai'); + + return items + .flatMap((item) => + item.events.map((event) => { + const upcomingDeadlines = event.timeline + .map((t) => DateTime.fromISO(t.deadline, { zone: event.timezone })) + .filter((d) => d > now) + .sort((a, b) => a.toMillis() - b.toMillis()); + + const nextDeadline = + upcomingDeadlines[0] || + DateTime.fromISO(event.timeline[event.timeline.length - 1].deadline, { + zone: event.timezone, + }); + const timeRemaining = nextDeadline.toMillis() - now.toMillis(); + const searchableText = [ + item.title, + item.description, + item.category, + item.tags.join(' '), + event.place, + nextDeadline.toFormat('yyyy-MM-dd MM yyyy'), + ] + .join(' ') + .toLowerCase(); + + return { item, event, nextDeadline, timeRemaining, searchableText }; + }), + ) + .sort((a, b) => { const aCompleted = a.timeRemaining < 0; const bCompleted = b.timeRemaining < 0; - // 如果一个已结束,一个未结束,未结束的排在前面 if (aCompleted && !bCompleted) return 1; if (!aCompleted && bCompleted) return -1; - - // 如果都未结束,按 timeRemaining 升序(即将到期的在前) - if (!aCompleted && !bCompleted) { - return a.timeRemaining - b.timeRemaining; - } - - // 如果都已结束,按 timeRemaining 降序(最近结束的在前) + if (!aCompleted && !bCompleted) return a.timeRemaining - b.timeRemaining; return b.timeRemaining - a.timeRemaining; }); - }, [ - eventsWithSearchDates, - selectedCategory, - selectedTags, - selectedLocations, - searchQuery, - showOnlyFavorites, - favorites, - ]); +} - if (loading) { - return ( -
-
-
-

{t('events.loading')}

-
-
- ); - } +export default async function ActivitiesPage({ + searchParams, +}: ActivitiesPageProps) { + const params = (await searchParams) ?? {}; + const query = single(params.query)?.trim() ?? ''; + const category = single(params.category)?.trim() ?? ''; + const currentPage = pageNumber(single(params.page)); + + const events = flattenEvents(await fetchActivities()).filter((flatEvent) => { + if (category && flatEvent.item.category !== category) return false; + if (!query) return true; + return flatEvent.searchableText.includes(query.toLowerCase()); + }); + + const totalPages = Math.max(1, Math.ceil(events.length / PAGE_SIZE)); + const page = Math.min(currentPage, totalPages); + const pageEvents = events.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); return (
- {/* 动态背景装饰 */}
-
- {/* Header Section */}

公益慈善活动截止日期 @@ -218,41 +144,81 @@ export default function Home() {

公益慈善会议、竞赛和活动重要截止日期概览,不再错过参与公益事业、奉献爱心和社会服务的机会

-
-

- 所有截止日期均默认转换为北京时间,如果您不知道当前所在时区,请点击时区选择器右侧的“自动检测” -

-

- *免责声明:本站数据由人工维护,仅供参考 -

-

- {/* Filters */} -
- -
+
+
+ + +
+
+
+ {categories.map(({ value, label }) => ( + + ))} + + +
+ +
+
- {/* Events List */}
- {filteredEvents.map(({ item, event }) => ( - + {pageEvents.map(({ item, event }) => ( + + + ))}
- {filteredEvents.length === 0 && ( + {events.length === 0 && (
🔍

- {t('events.notFound')} + 没有找到活动

- {t('events.hint')} + 请调整关键词或分类后重试

)} - {/* Footer */} + +

diff --git a/app/api/data/route.ts b/app/api/data/route.ts index cda253e..703b36e 100644 --- a/app/api/data/route.ts +++ b/app/api/data/route.ts @@ -1,23 +1,11 @@ import { NextResponse } from 'next/server'; -import { - ACTIVITIES_API_URL, - ExternalDeadlineItem, - transformItem, -} from '@/lib/activities'; +import { fetchActivities } from '@/lib/activities'; export const dynamic = 'force-static'; export async function GET() { try { - const res = await fetch(ACTIVITIES_API_URL, { cache: 'force-cache' }); - if (!res.ok) { - return NextResponse.json( - { error: 'Failed to fetch data from external API' }, - { status: 502 }, - ); - } - const externalData = (await res.json()) as ExternalDeadlineItem[]; - const data = externalData.map(transformItem); + const data = await fetchActivities(); return NextResponse.json(data); } catch (err) { console.error('Failed to fetch data from external API:', err); diff --git a/app/restaurants/page.tsx b/app/restaurants/page.tsx index 1e226e5..181ca05 100644 --- a/app/restaurants/page.tsx +++ b/app/restaurants/page.tsx @@ -1,16 +1,36 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { ArrowRight, Loader2, MapPin } from 'lucide-react'; -import Link from 'next/link'; -import { useTranslation } from 'react-i18next'; import FoodAIDialog from '@/components/FoodAIDialog'; +import { Pagination } from '@/components/Pagination'; +import { RestaurantNavigationButton } from '@/components/RestaurantNavigationButton'; import SafeTranslation from '@/components/SafeTranslation'; import { fetchBitesCatalog, BitesRestaurant } from '@/lib/bitesCatalog'; +import { ArrowRight, Search } from 'lucide-react'; +import Link from 'next/link'; import styles from './page.module.css'; +const PAGE_SIZE = 10; + type FilterType = 'all' | 'hearing' | 'visual' | 'wheelchair' | 'cognitive'; +interface RestaurantsPageProps { + searchParams?: Promise>; +} + +const filters: { value: FilterType; label: string }[] = [ + { value: 'all', label: '全部' }, + { value: 'hearing', label: '听障友好' }, + { value: 'visual', label: '视障友好' }, + { value: 'wheelchair', label: '轮椅友好' }, + { value: 'cognitive', label: '认知友好' }, +]; + +const single = (value: string | string[] | undefined) => + Array.isArray(value) ? value[0] : value; + +const pageNumber = (value: string | undefined) => { + const page = Number.parseInt(value || '1', 10); + return Number.isFinite(page) && page > 0 ? page : 1; +}; + function getAccessibilityTypes(r: BitesRestaurant): FilterType[] { const types: FilterType[] = []; if (r.accessibility.deafFriendly) types.push('hearing'); @@ -21,91 +41,45 @@ function getAccessibilityTypes(r: BitesRestaurant): FilterType[] { return types; } -export default function BarrierFreeBitesPage() { - const { t } = useTranslation('common'); - const [filter, setFilter] = useState('all'); - const [restaurants, setRestaurants] = useState([]); - const [loading, setLoading] = useState(true); - const [navigationLoading, setNavigationLoading] = useState( - null, - ); +function matchesQuery(restaurant: BitesRestaurant, query: string) { + if (!query) return true; + const text = [ + restaurant.name, + restaurant.description, + restaurant.city, + restaurant.address, + restaurant.tags.join(' '), + restaurant.food?.map((f) => f.name).join(' ') ?? '', + ] + .join(' ') + .toLowerCase(); + return text.includes(query.toLowerCase()); +} - useEffect(() => { - fetchBitesCatalog().then((data) => { - setRestaurants(data); - setLoading(false); - }); - }, []); +export default async function BarrierFreeBitesPage({ + searchParams, +}: RestaurantsPageProps) { + const params = (await searchParams) ?? {}; + const query = single(params.query)?.trim() ?? ''; + const filter = (single(params.filter)?.trim() || 'all') as FilterType; + const currentPage = pageNumber(single(params.page)); - const filteredRestaurants = restaurants.filter((r) => { - if (filter === 'all') return true; - return getAccessibilityTypes(r).includes(filter); + const restaurants = (await fetchBitesCatalog()).filter((restaurant) => { + if ( + filter !== 'all' && + !getAccessibilityTypes(restaurant).includes(filter) + ) { + return false; + } + return matchesQuery(restaurant, query); }); - const openAmapNavigation = (restaurant: BitesRestaurant) => { - const { name, address, lat, lng } = restaurant; - setNavigationLoading(name); - - if (lat && lng) { - const appUrl = `amapuri://route/plan/?dlat=${lat}&dlon=${lng}&dname=${encodeURIComponent(name)}&dev=0&t=0`; - const webUrl = `https://uri.amap.com/navigation?to=${lng},${lat},${encodeURIComponent(name)}&mode=car&policy=1&src=mypage`; - - const tryOpenApp = () => { - const iframe = document.createElement('iframe'); - iframe.style.display = 'none'; - iframe.src = appUrl; - document.body.appendChild(iframe); - - const timeout = setTimeout(() => { - document.body.removeChild(iframe); - window.open(webUrl, '_blank', 'noopener,noreferrer'); - setNavigationLoading(null); - }, 2000); - - const handleVisibilityChange = () => { - if (document.hidden) { - clearTimeout(timeout); - document.body.removeChild(iframe); - document.removeEventListener( - 'visibilitychange', - handleVisibilityChange, - ); - setNavigationLoading(null); - } - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - - setTimeout(() => { - try { - if (iframe.parentNode) document.body.removeChild(iframe); - document.removeEventListener( - 'visibilitychange', - handleVisibilityChange, - ); - } catch { - // ignore cleanup errors - } - }, 3000); - }; - - const isMobile = - /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( - navigator.userAgent, - ); - - if (isMobile) { - tryOpenApp(); - } else { - window.open(webUrl, '_blank', 'noopener,noreferrer'); - setNavigationLoading(null); - } - } else { - const markerUrl = `https://uri.amap.com/marker?address=${encodeURIComponent(address)}&name=${encodeURIComponent(name)}`; - window.open(markerUrl, '_blank', 'noopener,noreferrer'); - setNavigationLoading(null); - } - }; + const totalPages = Math.max(1, Math.ceil(restaurants.length / PAGE_SIZE)); + const page = Math.min(currentPage, totalPages); + const pageRestaurants = restaurants.slice( + (page - 1) * PAGE_SIZE, + page * PAGE_SIZE, + ); return (

@@ -137,57 +111,41 @@ export default function BarrierFreeBitesPage() {
-
- - - - - -
+
+
+ {filters.map(({ value, label }) => ( + + ))} + +
+
- {loading ? ( -
- -
- ) : filteredRestaurants.length === 0 ? ( + {pageRestaurants.length === 0 ? (
) : ( - filteredRestaurants.map((restaurant) => { + pageRestaurants.map((restaurant) => { const types = getAccessibilityTypes(restaurant); - const isNavigating = navigationLoading === restaurant.name; return (
{restaurant.accessibility.deafFriendly && ( - - 👂 - - + 听障友好 )} {restaurant.accessibility.blindFriendly && ( - - 👁️ - - + 视障友好 )} {types.includes('wheelchair') && ( - - - - + 轮椅友好 )}
@@ -244,27 +183,17 @@ export default function BarrierFreeBitesPage() {

{restaurant.tags.length > 0 && (
-

- -

+

特色服务

    - {restaurant.tags.map((tag, i) => ( -
  • {tag}
  • + {restaurant.tags.map((tag) => ( +
  • {tag}
  • ))}
)} {restaurant.food && restaurant.food.length > 0 && (
- - - + 美食类型 {restaurant.food.map((f) => f.name).join('、')} @@ -272,46 +201,23 @@ export default function BarrierFreeBitesPage() { )}
- - - + 地址 {restaurant.address} - +
- {t('detail.viewDetails')} + 查看详情
@@ -322,37 +228,30 @@ export default function BarrierFreeBitesPage() { )}
- {/* 关于部分 */} + +
-

- -

+

关于无障碍美食

- + 无障碍美食致力于为残障人士提供平等的用餐体验。我们精选了各地的无障碍友好餐厅,涵盖听障、视障、轮椅使用者和认知障碍人士的需求。

- + 每家餐厅都经过实地考察,确保提供真正的无障碍服务。我们希望通过这个平台,让更多人了解和支持无障碍餐饮,共同创造一个更包容的社会。

- {/* AI 美食推荐对话框触发器 */}
); diff --git a/app/tutoring/page.tsx b/app/tutoring/page.tsx index 8a5bb7d..f47bb69 100644 --- a/app/tutoring/page.tsx +++ b/app/tutoring/page.tsx @@ -1,6 +1,3 @@ -'use client'; - -import { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { ArrowRight, @@ -9,40 +6,55 @@ import { GraduationCap, Search, } from 'lucide-react'; +import { Pagination } from '@/components/Pagination'; import { fetchTutoringCatalog, TutoringCourse } from '@/lib/tutoring'; -export default function TutoringPage() { - const [courses, setCourses] = useState([]); - const [loading, setLoading] = useState(true); - const [query, setQuery] = useState(''); - const [selectedTag, setSelectedTag] = useState(null); +const PAGE_SIZE = 10; + +interface TutoringPageProps { + searchParams?: Promise>; +} + +const single = (value: string | string[] | undefined) => + Array.isArray(value) ? value[0] : value; + +const pageNumber = (value: string | undefined) => { + const page = Number.parseInt(value || '1', 10); + return Number.isFinite(page) && page > 0 ? page : 1; +}; + +function matchesQuery(course: TutoringCourse, query: string) { + if (!query) return true; + const q = query.toLowerCase(); + return ( + course.title.toLowerCase().includes(q) || + course.summary.toLowerCase().includes(q) || + course.tags.some((t) => t.toLowerCase().includes(q)) || + (course.instructor ?? '').toLowerCase().includes(q) + ); +} + +export default async function TutoringPage({ + searchParams, +}: TutoringPageProps) { + const params = (await searchParams) ?? {}; + const query = single(params.query)?.trim() ?? ''; + const selectedTag = single(params.tag)?.trim() ?? ''; + const currentPage = pageNumber(single(params.page)); - useEffect(() => { - fetchTutoringCatalog().then((data) => { - setCourses(data); - setLoading(false); - }); - }, []); + const courses = await fetchTutoringCatalog(); + const allTags = Array.from( + new Set(courses.flatMap((course) => course.tags)), + ).sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')); - const allTags = useMemo(() => { - const set = new Set(); - courses.forEach((c) => c.tags.forEach((t) => set.add(t))); - return Array.from(set); - }, [courses]); + const filtered = courses.filter((course) => { + if (selectedTag && !course.tags.includes(selectedTag)) return false; + return matchesQuery(course, query); + }); - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - return courses.filter((c) => { - if (selectedTag && !c.tags.includes(selectedTag)) return false; - if (!q) return true; - return ( - c.title.toLowerCase().includes(q) || - c.summary.toLowerCase().includes(q) || - c.tags.some((t) => t.toLowerCase().includes(q)) || - (c.instructor ?? '').toLowerCase().includes(q) - ); - }); - }, [courses, query, selectedTag]); + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const page = Math.min(currentPage, totalPages); + const pageCourses = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); return (
@@ -71,54 +83,68 @@ export default function TutoringPage() {
-
+
setQuery(e.target.value)} + defaultValue={query} />
{allTags.length > 0 && (
- + {allTags.map((tag) => ( - + ))} +
)} -
+ - {loading ? ( -
-
-

正在加载课程...

-
- ) : filtered.length === 0 ? ( + {filtered.length === 0 ? (
📚

@@ -128,7 +154,7 @@ export default function TutoringPage() {

) : (
- {filtered.map((course) => ( + {pageCourses.map((course) => ( )} + +
); diff --git a/components/FavoriteEventsFilter.tsx b/components/FavoriteEventsFilter.tsx new file mode 100644 index 0000000..4ac8f2b --- /dev/null +++ b/components/FavoriteEventsFilter.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { useEventStore } from '@/lib/store'; +import { Star } from 'lucide-react'; +import { ReactNode, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +export function FavoriteEventsToggle() { + const { t } = useTranslation('common'); + const { mounted, showOnlyFavorites, setShowOnlyFavorites } = useEventStore(); + + useEffect(() => { + useEventStore.setState({ mounted: true }); + }, []); + + if (!mounted) return null; + + return ( + + ); +} + +export function FavoriteEventItem({ + eventId, + children, +}: { + eventId: string; + children: ReactNode; +}) { + const { mounted, favorites, showOnlyFavorites } = useEventStore(); + + if (mounted && showOnlyFavorites && !favorites.includes(eventId)) { + return null; + } + + return children; +} diff --git a/components/Pagination.tsx b/components/Pagination.tsx new file mode 100644 index 0000000..458eddd --- /dev/null +++ b/components/Pagination.tsx @@ -0,0 +1,51 @@ +import Link from 'next/link'; + +interface PaginationProps { + page: number; + totalPages: number; + searchParams: Record; +} + +export function Pagination({ + page, + totalPages, + searchParams, +}: PaginationProps) { + if (totalPages <= 1) return null; + + const pages = Array.from({ length: totalPages }, (_, index) => index + 1); + + const hrefFor = (targetPage: number) => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(searchParams)) { + if (value && key !== 'page') params.set(key, value); + } + if (targetPage > 1) params.set('page', String(targetPage)); + const query = params.toString(); + return query ? `?${query}` : '?'; + }; + + return ( + + ); +} diff --git a/components/RestaurantNavigationButton.tsx b/components/RestaurantNavigationButton.tsx new file mode 100644 index 0000000..0ea8cb9 --- /dev/null +++ b/components/RestaurantNavigationButton.tsx @@ -0,0 +1,103 @@ +'use client'; + +import SafeTranslation from '@/components/SafeTranslation'; +import { Loader2, MapPin } from 'lucide-react'; +import { useState } from 'react'; + +interface RestaurantNavigationButtonProps { + name: string; + address: string; + lat?: number; + lng?: number; +} + +export function RestaurantNavigationButton({ + name, + address, + lat, + lng, +}: RestaurantNavigationButtonProps) { + const [loading, setLoading] = useState(false); + + const openAmapNavigation = () => { + setLoading(true); + + if (lat && lng) { + const appUrl = `amapuri://route/plan/?dlat=${lat}&dlon=${lng}&dname=${encodeURIComponent(name)}&dev=0&t=0`; + const webUrl = `https://uri.amap.com/navigation?to=${lng},${lat},${encodeURIComponent(name)}&mode=car&policy=1&src=mypage`; + + const isMobile = + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent, + ); + + if (isMobile) { + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + iframe.src = appUrl; + document.body.appendChild(iframe); + + const timeout = window.setTimeout(() => { + iframe.remove(); + window.open(webUrl, '_blank', 'noopener,noreferrer'); + setLoading(false); + }, 2000); + + const handleVisibilityChange = () => { + if (document.hidden) { + window.clearTimeout(timeout); + iframe.remove(); + document.removeEventListener( + 'visibilitychange', + handleVisibilityChange, + ); + setLoading(false); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + window.setTimeout(() => { + iframe.remove(); + document.removeEventListener( + 'visibilitychange', + handleVisibilityChange, + ); + }, 3000); + return; + } + + window.open(webUrl, '_blank', 'noopener,noreferrer'); + setLoading(false); + return; + } + + const markerUrl = `https://uri.amap.com/marker?address=${encodeURIComponent(address)}&name=${encodeURIComponent(name)}`; + window.open(markerUrl, '_blank', 'noopener,noreferrer'); + setLoading(false); + }; + + return ( + + ); +} diff --git a/lib/activities.ts b/lib/activities.ts index 9096ca7..a2a7e62 100644 --- a/lib/activities.ts +++ b/lib/activities.ts @@ -55,3 +55,19 @@ export function transformItem(item: ExternalDeadlineItem): DeadlineItem { events: item.events.map(transformEvent), }; } + +let _activitiesCache: DeadlineItem[] | null = null; + +export async function fetchActivities(): Promise { + if (_activitiesCache) return _activitiesCache; + try { + const res = await fetch(ACTIVITIES_API_URL, { cache: 'force-cache' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const externalData = (await res.json()) as ExternalDeadlineItem[]; + _activitiesCache = externalData.map(transformItem); + return _activitiesCache; + } catch (err) { + console.error('Failed to fetch activities from external API:', err); + return []; + } +}