feat: migrate to Next.js for Mini App and enhance project structure
- Replaced the previous webapp with a new Mini App built using Next.js, improving performance and maintainability. - Updated the `.gitignore` to exclude Next.js build artifacts and node modules. - Revised documentation in `AGENTS.md`, `README.md`, and `architecture.md` to reflect the new Mini App structure and technology stack. - Enhanced Dockerfile to support the new build process for the Next.js application. - Updated CI workflow to build and test the Next.js application. - Added new configuration options for the Mini App, including `MINI_APP_SHORT_NAME` for improved deep linking. - Refactored frontend testing setup to accommodate the new structure and testing framework. - Removed legacy webapp files and dependencies to streamline the project.
This commit is contained in:
195
webapp-next/src/components/CalendarPage.tsx
Normal file
195
webapp-next/src/components/CalendarPage.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Calendar view layout: header, grid, duty list, day detail.
|
||||
* Composes calendar UI and owns sticky scroll, swipe, month data, and day-detail ref.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMonthData } from "@/hooks/use-month-data";
|
||||
import { useSwipe } from "@/hooks/use-swipe";
|
||||
import { useStickyScroll } from "@/hooks/use-sticky-scroll";
|
||||
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||
import { CalendarHeader } from "@/components/calendar/CalendarHeader";
|
||||
import { CalendarGrid } from "@/components/calendar/CalendarGrid";
|
||||
import { DutyList, DutyListSkeleton } from "@/components/duty/DutyList";
|
||||
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
|
||||
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||
import { LoadingState } from "@/components/states/LoadingState";
|
||||
import { ErrorState } from "@/components/states/ErrorState";
|
||||
import { AccessDenied } from "@/components/states/AccessDenied";
|
||||
|
||||
/** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */
|
||||
const STICKY_HEIGHT_FALLBACK_PX = 268;
|
||||
|
||||
export interface CalendarPageProps {
|
||||
/** Whether the user is allowed (for data loading). */
|
||||
isAllowed: boolean;
|
||||
/** Raw initData string for API auth. */
|
||||
initDataRaw: string | undefined;
|
||||
}
|
||||
|
||||
export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
||||
const dayDetailRef = useRef<DayDetailHandle>(null);
|
||||
const calendarStickyRef = useRef<HTMLDivElement>(null);
|
||||
const [stickyBlockHeight, setStickyBlockHeight] = useState(STICKY_HEIGHT_FALLBACK_PX);
|
||||
|
||||
useEffect(() => {
|
||||
const el = calendarStickyRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) setStickyBlockHeight(entry.contentRect.height);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
currentMonth,
|
||||
loading,
|
||||
error,
|
||||
accessDenied,
|
||||
accessDeniedDetail,
|
||||
duties,
|
||||
calendarEvents,
|
||||
selectedDay,
|
||||
nextMonth,
|
||||
prevMonth,
|
||||
setCurrentMonth,
|
||||
setSelectedDay,
|
||||
} = useAppStore(
|
||||
useShallow((s) => ({
|
||||
currentMonth: s.currentMonth,
|
||||
loading: s.loading,
|
||||
error: s.error,
|
||||
accessDenied: s.accessDenied,
|
||||
accessDeniedDetail: s.accessDeniedDetail,
|
||||
duties: s.duties,
|
||||
calendarEvents: s.calendarEvents,
|
||||
selectedDay: s.selectedDay,
|
||||
nextMonth: s.nextMonth,
|
||||
prevMonth: s.prevMonth,
|
||||
setCurrentMonth: s.setCurrentMonth,
|
||||
setSelectedDay: s.setSelectedDay,
|
||||
}))
|
||||
);
|
||||
|
||||
const { retry } = useMonthData({
|
||||
initDataRaw,
|
||||
enabled: isAllowed,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const isCurrentMonth =
|
||||
currentMonth.getFullYear() === now.getFullYear() &&
|
||||
currentMonth.getMonth() === now.getMonth();
|
||||
useAutoRefresh(retry, isCurrentMonth);
|
||||
|
||||
const navDisabled = loading || accessDenied || selectedDay !== null;
|
||||
const handlePrevMonth = useCallback(() => {
|
||||
if (navDisabled) return;
|
||||
prevMonth();
|
||||
}, [navDisabled, prevMonth]);
|
||||
const handleNextMonth = useCallback(() => {
|
||||
if (navDisabled) return;
|
||||
nextMonth();
|
||||
}, [navDisabled, nextMonth]);
|
||||
|
||||
useSwipe(
|
||||
calendarStickyRef,
|
||||
handleNextMonth,
|
||||
handlePrevMonth,
|
||||
{ threshold: 50, disabled: navDisabled }
|
||||
);
|
||||
useStickyScroll(calendarStickyRef);
|
||||
|
||||
const handleDayClick = useCallback(
|
||||
(dateKey: string, anchorRect: DOMRect) => {
|
||||
const [y, m] = dateKey.split("-").map(Number);
|
||||
if (
|
||||
y !== currentMonth.getFullYear() ||
|
||||
m !== currentMonth.getMonth() + 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
dayDetailRef.current?.openWithRect(dateKey, anchorRect);
|
||||
},
|
||||
[currentMonth]
|
||||
);
|
||||
|
||||
const handleCloseDayDetail = useCallback(() => {
|
||||
setSelectedDay(null);
|
||||
}, [setSelectedDay]);
|
||||
|
||||
const handleGoToToday = useCallback(() => {
|
||||
setCurrentMonth(new Date());
|
||||
retry();
|
||||
}, [setCurrentMonth, retry]);
|
||||
|
||||
const isInitialLoad =
|
||||
loading && duties.length === 0 && calendarEvents.length === 0;
|
||||
|
||||
// Signal Telegram to hide loading when calendar first load finishes.
|
||||
useEffect(() => {
|
||||
if (!isInitialLoad) {
|
||||
callMiniAppReadyOnce();
|
||||
}
|
||||
}, [isInitialLoad]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
|
||||
<div
|
||||
ref={calendarStickyRef}
|
||||
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
|
||||
>
|
||||
<CalendarHeader
|
||||
month={currentMonth}
|
||||
isLoading={loading}
|
||||
disabled={navDisabled}
|
||||
onGoToToday={handleGoToToday}
|
||||
onRefresh={retry}
|
||||
onPrevMonth={handlePrevMonth}
|
||||
onNextMonth={handleNextMonth}
|
||||
/>
|
||||
{isInitialLoad ? (
|
||||
<LoadingState
|
||||
asPlaceholder
|
||||
className="min-h-[var(--calendar-block-min-height)]"
|
||||
/>
|
||||
) : (
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={duties}
|
||||
calendarEvents={calendarEvents}
|
||||
onDayClick={handleDayClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{accessDenied && (
|
||||
<AccessDenied serverDetail={accessDeniedDetail} className="my-3" />
|
||||
)}
|
||||
{!accessDenied && error && (
|
||||
<ErrorState message={error} onRetry={retry} className="my-3" />
|
||||
)}
|
||||
{!accessDenied && !error && loading && !isInitialLoad ? (
|
||||
<DutyListSkeleton className="mt-2" />
|
||||
) : !accessDenied && !error && !isInitialLoad ? (
|
||||
<DutyList
|
||||
scrollMarginTop={stickyBlockHeight}
|
||||
className="mt-2"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DayDetail
|
||||
ref={dayDetailRef}
|
||||
duties={duties}
|
||||
calendarEvents={calendarEvents}
|
||||
onClose={handleCloseDayDetail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user