diff --git a/.nvmrc b/.nvmrc index 0a47c85..941ea48 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/iron \ No newline at end of file +lts/krypton \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..62eb59b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# Code Guidelines + +You must follow these rules strictly: + +## PRIORITIZE SOUND SOLUTIONS + +- First choose a solution that fully solves the problem and is clear, maintainable, and consistent with the codebase. +- Prefer smaller changes only when they do not make the solution worse. Minimal diffs are a tiebreaker, not the primary goal. +- Do not preserve awkward structure, duplication, or poor boundaries just to touch fewer lines. + +## DO NOT OVER-SCOPE + +- Only make changes that are directly requested or clearly necessary for a complete solution. +- Don't add unrelated features or speculative refactors. But if the cleanest correct fix requires changing adjacent code, make that change instead of forcing an awkward minimal patch. +- Don't add docstrings, comments, or type annotations to code you didn't change. Only add comments where the logic isn't self-evident. +- Don't use feature flags when you can just change the code. + +## DO NOT OVER-ABSTRACT + +- Keep implementation details private. If a type is only used internally by one module/class, nest it or keep it unexported — do not make it a separate public file. +- Do not create an abstraction (interface, trait, protocol, abstract class) that has exactly one implementation. That is not abstraction, it is indirection. +- Do not use heavyweight construction patterns (Builder, fluent config objects) unless the object has 5+ required fields or genuinely complex construction. Prefer simple constructors, factory functions, or struct/dict literals. +- Do not extract trivial one-liner methods that add no semantic value. If the method body is as clear as the method name, inline it. +- Don't create helpers, utilities, or abstractions for one-time operations. Don't design for hypothetical future requirements. Prefer the simplest design that solves the current task cleanly; three similar lines of code is better than a premature abstraction, but not if avoiding the abstraction makes the result more awkward. +- Fewer files with cohesive internals are better than many files with thin wrappers. +- Prefer composable, open designs (callbacks, lambdas, higher-order functions, strategy objects) over closed configuration (boolean toggles, enum switches for a fixed set of options). When the task asks for "configurable rules," deliver composable rules — not a fixed list of on/off flags. + +## DO NOT OVER-DEFEND + +- Only add defensive checks (null/nil/None checks, type guards, boundary validation) at true system boundaries — public API entry points that accept external, untrusted input. +- Do not add defensive checks in internal/private functions, constructors called only by your own code, or test helpers. +- Do not add defensive copies unless the data is genuinely shared across trust boundaries. +- Omitting a defensive check is not a bug — it is a deliberate signal that the caller is trusted. + +## USE MODERN LANGUAGE FEATURES + +- Write idiomatic code for the language version specified by the project. Do not write code that targets an older version out of habit. +- Prefer language-level constructs that reduce boilerplate: pattern matching, destructuring, algebraic data types (sealed types, tagged unions, enums with data), data classes/records/structs, and built-in concurrency primitives. +- If the language provides exhaustiveness checking (e.g., sealed types + switch, match expressions, tagged unions), use it. Compiler-enforced completeness is better than a default/else branch that hides missing cases. +- Do not manually write what the language generates for free (toString, equality, hash, serialization). + +## RESPECT THE EXISTING CODEBASE + +- Before writing new code, read the surrounding module to understand its conventions: error handling style, dependency injection approach, module organization, test patterns, naming idioms. +- Reuse existing utilities, helpers, and internal patterns. Do not introduce a "locally better but globally alien" approach when the project already has an established way to do the same thing. +- Your code should look like it was written by someone who already works on this project. If placed anonymously into the repository, a project-familiar reviewer should not find it stylistically jarring. +- Do not introduce new libraries, frameworks, paradigms, or organizational patterns unless the task explicitly requires it and no existing project convention covers the need. +- When removing code, remove it completely: no compatibility shims, no re-exports of old names, no `// removed` comments, no dead forwarding layers kept "just in case." If it is unused, delete it. +- When refactoring, do not preserve intermediate layers solely to avoid updating call sites. Update the call sites. + +## WRITE MEANINGFUL TESTS + +- Do not write tests unless the user explicitly asks for them. +- Test the interesting behavior, not the trivial paths. Prioritize edge cases, error conditions, and concurrency scenarios over "happy path only" coverage. +- Structure test output so that individual failures are identifiable — do not let the first assertion crash the entire suite with no indication of what else passed or failed. +- If the implementation supports a feature (cancellation, composition, error recovery), test it. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 1c44f1f..6d3abcf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,71 +1,118 @@ -FROM node:20-alpine AS base +# ============================================ +# Stage 1: Dependencies Installation Stage +# ============================================ -# Install dependencies only when needed -FROM base AS deps -# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat +# IMPORTANT: Node.js Version Maintenance +# This Dockerfile uses Node.js 24.13.0-slim, which was the latest LTS version at the time of writing. +# To ensure security and compatibility, regularly update the NODE_VERSION ARG to the latest LTS version. +ARG NODE_VERSION=24.13.0-slim + +FROM node:${NODE_VERSION} AS dependencies + +# Set working directory WORKDIR /app -# Install dependencies based on the preferred package manager -COPY package.json yarn.lock* .yarnrc.yml package-lock.json* pnpm-lock.yaml* ./ -RUN \ - if [ -f yarn.lock ]; then corepack enable&&yarn --immutable; \ - elif [ -f package-lock.json ]; then npm ci; \ - elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \ - else echo "Lockfile not found." && exit 1; \ +# Copy package-related files first to leverage Docker's caching mechanism +COPY package.json yarn.lock* .yarnrc.yml package-lock.json* pnpm-lock.yaml* .npmrc* ./ + +# Install project dependencies with frozen lockfile for reproducible builds +RUN --mount=type=cache,target=/root/.npm \ + --mount=type=cache,target=/usr/local/share/.cache/yarn \ + --mount=type=cache,target=/root/.local/share/pnpm/store \ + if [ -f package-lock.json ]; then \ + npm ci --no-audit --no-fund; \ + elif [ -f yarn.lock ]; then \ + corepack enable yarn && yarn install --immutable; \ + elif [ -f pnpm-lock.yaml ]; then \ + corepack enable pnpm && pnpm install --frozen-lockfile; \ + else \ + echo "No lockfile found." && exit 1; \ fi +# ============================================ +# Stage 2: Build Next.js application in standalone mode +# ============================================ + +FROM node:${NODE_VERSION} AS builder +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* -# Rebuild the source code only when needed -FROM base AS builder -RUN apk add --no-cache git +# Set working directory WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules + +# Copy project dependencies from dependencies stage +COPY --from=dependencies /app/node_modules ./node_modules + +# Copy application source code COPY . . +ENV NODE_ENV=production + # Next.js collects completely anonymous telemetry data about general usage. # Learn more here: https://nextjs.org/telemetry # Uncomment the following line in case you want to disable telemetry during the build. -# ENV NEXT_TELEMETRY_DISABLED 1 +# ENV NEXT_TELEMETRY_DISABLED=1 + +# Build Next.js application +# If you want to speed up Docker rebuilds, you can cache the build artifacts +# by adding: --mount=type=cache,target=/app/.next/cache +# This caches the .next/cache directory across builds, but it also prevents +# .next/cache/fetch-cache from being included in the final image, meaning +# cached fetch responses from the build won't be available at runtime. +RUN if [ -f package-lock.json ]; then \ + npm run build; \ + elif [ -f yarn.lock ]; then \ + corepack enable yarn && yarn build; \ + elif [ -f pnpm-lock.yaml ]; then \ + corepack enable pnpm && pnpm build; \ + else \ + echo "No lockfile found." && exit 1; \ + fi -RUN corepack enable&&yarn build +# ============================================ +# Stage 3: Run Next.js application +# ============================================ -# If using npm comment out above and use below instead -# RUN npm run build +FROM node:${NODE_VERSION} AS runner -# Production image, copy all the files and run next -FROM base AS runner +# Set working directory WORKDIR /app -ENV NODE_ENV production -# Uncomment the following line in case you want to disable telemetry during runtime. -# ENV NEXT_TELEMETRY_DISABLED 1 +# Set production environment variables +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the run time. +# ENV NEXT_TELEMETRY_DISABLED=1 -COPY --from=builder /app/public ./public +# Copy production assets +COPY --from=builder --chown=node:node /app/public ./public # Set the correct permission for prerender cache RUN mkdir .next -RUN chown nextjs:nodejs .next +RUN chown node:node .next # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static #No idea why next-i18n want this -COPY --from=builder --chown=nextjs:nodejs /app/next.config.js ./next.config.js -COPY --from=builder --chown=nextjs:nodejs /app/next-i18next.config.js ./next-i18next.config.js +COPY --from=builder --chown=node:node /app/next.config.js ./next.config.js +COPY --from=builder --chown=node:node /app/next-i18next.config.js ./next-i18next.config.js -USER nextjs +# If you want to persist the fetch cache generated during the build so that +# cached responses are available immediately on startup, uncomment this line: +# COPY --from=builder --chown=node:node /app/.next/cache ./.next/cache -EXPOSE 3000 +# Switch to non-root user for security best practices +USER node -ENV PORT 3000 -# set hostname to localhost -ENV HOSTNAME "0.0.0.0" +# Expose port 3000 to allow HTTP traffic +EXPOSE 3000 -# server.js is created by next build from the standalone output -# https://nextjs.org/docs/pages/api-reference/next-config-js/output -CMD ["node", "server.js"] +# Start Next.js standalone server +CMD ["node", "server.js"] \ No newline at end of file diff --git a/additional.d.ts b/additional.d.ts index b6c43c3..f917288 100644 --- a/additional.d.ts +++ b/additional.d.ts @@ -1,3 +1,56 @@ declare const VERSION: string; declare const COMMITHASH: string; declare const BRANCH: string; + +type TrackEventValue = Record; +type TMapLatLngInstance = object; + +interface AnalyticsTracker { + track: (eventName: string, eventValue?: TrackEventValue) => void; +} + +interface TMapMapInstance { + on: (eventName: string, handler: () => void) => void; +} + +interface TMapNamespace { + LatLng: new (lat: number | undefined, lng: number | undefined) => TMapLatLngInstance; + Map: new ( + container: HTMLElement | null, + options: { + center: TMapLatLngInstance; + zoom: number; + pitch: number; + rotation: number; + } + ) => TMapMapInstance; + MultiMarker: new (options: { + id: string; + map: TMapMapInstance; + styles: { + marker: unknown; + }; + geometries: Array<{ + id: string; + styleId: string; + position: TMapLatLngInstance; + properties: { + title: string; + }; + }>; + }) => unknown; + MarkerStyle: new (options: { + width: number; + height: number; + anchor: { + x: number; + y: number; + }; + }) => unknown; +} + +interface Window { + gtag?: (command: "event", eventName: string, eventValue?: TrackEventValue) => void; + TMap?: TMapNamespace; + umami?: AnalyticsTracker; +} diff --git a/package.json b/package.json index 7a2cfa6..9efcae5 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "next build --webpack", "start": "next start", "lint": "eslint .", + "typecheck": "tsc --noEmit", "clean": "rm -rf .next && rm -rf out", "turbo-analyze": "npx next experimental-analyze", "webpack-analyze": "ANALYZE=true yarn build" @@ -15,7 +16,7 @@ "@headlessui/react": "^2.2.10", "@next/third-parties": "^16.2.3", "@sentry/nextjs": "^10.48.0", - "autoprefixer": "^10.4.27", + "autoprefixer": "^10.5.0", "axios": "^1.15.0", "clsx": "^2.1.1", "dayjs": "^1.11.20", @@ -25,17 +26,18 @@ "eslint": "9.39.4", "eslint-config-next": "16.2.3", "git-revision-webpack-plugin": "^5.0.0", - "i18next": "^25.10.10", + "i18next": "^26.0.4", "next": "16.2.3", - "next-i18next": "^15.4.3", + "next-i18next": "^16.0.5", + "nuqs": "^2.8.9", "postcss": "^8.5.9", "postcss-flexbugs-fixes": "^5.0.2", "postcss-nested": "^7.0.2", - "postcss-preset-env": "^11.2.0", + "postcss-preset-env": "^11.2.1", "react": "19.2.5", "react-dom": "19.2.5", "react-hot-toast": "^2.6.0", - "react-i18next": "^16.6.6", + "react-i18next": "^17.0.3", "react-icons": "^5.6.0", "tailwindcss": "^3.4.19", "typescript": "5.9.3", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index b68fb56..9a45657 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1,6 +1,6 @@ { "header.title": "FurConsCalendar", - "header.slogan": "Happy New Year! 🎉", + "header.slogan": "Waiting for vacation? 🐕", "header.nav.homepage": "Home", "header.nav.city": "Cities", "header.nav.organization": "Organizers", diff --git a/public/locales/zh-Hans/common.json b/public/locales/zh-Hans/common.json index 0872d4e..80007b7 100644 --- a/public/locales/zh-Hans/common.json +++ b/public/locales/zh-Hans/common.json @@ -1,6 +1,6 @@ { "header.title": "兽展日历", - "header.slogan": "新年快乐🎉", + "header.slogan": "五一你去哪?🐕", "header.nav.homepage": "首页", "header.nav.city": "城市", "header.nav.organization": "展商", @@ -59,6 +59,8 @@ "event.goToSource": "前往信源", "event.map": "展会地图", "event.mapLoading": "正在加载地图", + "event.mapLoadFailed": "地图加载失败,请稍后重试", + "event.retryLoadMap": "尝试重新加载地图", "event.gotoGaoDe": "去高德地图查看", "event.gotoOrganization": "看看展商详情", "event.dateFormat": "YYYY年MM月DD日", diff --git a/public/locales/zh-Hant/common.json b/public/locales/zh-Hant/common.json index ddec935..0e7020a 100644 --- a/public/locales/zh-Hant/common.json +++ b/public/locales/zh-Hant/common.json @@ -59,6 +59,8 @@ "event.goToSource": "查看原始資訊", "event.map": "活動地圖", "event.mapLoading": "地圖載入中", + "event.mapLoadFailed": "地圖載入失敗,請稍後再試", + "event.retryLoadMap": "重新載入地圖", "event.gotoGaoDe": "用高德地圖查看", "event.gotoOrganization": "查看主辦單位", "event.dateFormat": "YYYY年MM月DD日", diff --git a/src/api/events.ts b/src/api/events.ts index 7595844..fb9d8e4 100644 --- a/src/api/events.ts +++ b/src/api/events.ts @@ -1,4 +1,5 @@ -import { EventItem, EventSchema } from "@/types/event"; +import { EventSchema } from "@/types/event"; +import type { EventItem } from "@/types/event"; import { ListResponse } from "@/types/api"; import API from "@/api"; diff --git a/src/api/organizations.ts b/src/api/organizations.ts index aae829f..b714a18 100644 --- a/src/api/organizations.ts +++ b/src/api/organizations.ts @@ -1,7 +1,7 @@ -import { Organization } from "@/types/organization"; +import type { Organization } from "@/types/organization"; import API from "@/api"; import { ListResponse } from "@/types/api"; -import { EventItem } from "@/types/event"; +import type { EventItem } from "@/types/event"; export class OrganizationsAPI { static async getOrganizationList(params: { current: string; pageSize: string; sortBy?: string }) { diff --git a/src/components/OrganizationLinkButton/index.tsx b/src/components/OrganizationLinkButton/index.tsx index 6863b68..cacd8f9 100644 --- a/src/components/OrganizationLinkButton/index.tsx +++ b/src/components/OrganizationLinkButton/index.tsx @@ -5,7 +5,7 @@ import { FaPaw } from "react-icons/fa"; import { HiOutlineHome, HiOutlineMail } from "react-icons/hi"; import { FaQq, FaTwitter, FaWeibo } from "react-icons/fa"; import { SiBilibili, SiXiaohongshu } from "react-icons/si"; -import { TFunction } from "next-i18next"; +import type { TFunction } from "next-i18next/pages"; import { FaFacebook } from "react-icons/fa6"; import { ReactNode } from "react"; diff --git a/src/components/SimpleEventCard/index.tsx b/src/components/SimpleEventCard/index.tsx index 4a89d97..1e69d71 100644 --- a/src/components/SimpleEventCard/index.tsx +++ b/src/components/SimpleEventCard/index.tsx @@ -3,8 +3,8 @@ import Link from "next/link"; import dayjs from "dayjs"; import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-tw"; -import { SimpleEventItem } from "@/types/event"; -import { useTranslation } from "next-i18next"; +import type { SimpleEventItem } from "@/types/event"; +import { useTranslation } from "next-i18next/pages"; import { getDayjsLocale } from "@/utils/locale"; function SimpleEventCard({ event }: { event: SimpleEventItem }) { diff --git a/src/components/event/EventMapCard.tsx b/src/components/event/EventMapCard.tsx new file mode 100644 index 0000000..c695d7a --- /dev/null +++ b/src/components/event/EventMapCard.tsx @@ -0,0 +1,173 @@ +import { parseCoordinates } from "@/utils/coordinate"; +import clsx from "clsx"; +import Script from "next/script"; +import { useTranslation } from "next-i18next/pages"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { VscLoading } from "react-icons/vsc"; +import { sendTrack } from "@/utils/track"; + +const MapLoadingStatus = { + Idle: "idle", + Loading: "loading", + Finished: "finished", + Error: "error", +} as const; + +type MapLoadingStatusType = (typeof MapLoadingStatus)[keyof typeof MapLoadingStatus]; + +type EventMapCardProps = { + latitudeText?: string | null; + longitudeText?: string | null; +}; + +export default function EventMapCard({ latitudeText, longitudeText }: EventMapCardProps) { + const { t } = useTranslation(); + const { latitude, longitude, isValid } = parseCoordinates(latitudeText, longitudeText); + const mapContainerRef = useRef(null); + const hasInitializedRef = useRef(false); + const [scriptReloadKey, setScriptReloadKey] = useState(0); + const [mapLoadingStatus, setMapLoadingStatus] = useState(() => { + if (isValid) { + return MapLoadingStatus.Loading; + } + return MapLoadingStatus.Idle; + }); + + const initMap = useCallback(() => { + if (!window.TMap || !isValid || !mapContainerRef.current || hasInitializedRef.current) { + return; + } + + setMapLoadingStatus(MapLoadingStatus.Loading); + + try { + const center = new window.TMap.LatLng(latitude, longitude); + const map = new window.TMap.Map(mapContainerRef.current, { + center, + zoom: 17.2, + pitch: 43.5, + rotation: 45, + }); + + hasInitializedRef.current = true; + + map.on("tilesloaded", () => { + setMapLoadingStatus(MapLoadingStatus.Finished); + }); + + new window.TMap.MultiMarker({ + id: "marker-layer", + map, + styles: { + marker: new window.TMap.MarkerStyle({ + width: 25, + height: 35, + anchor: { x: 16, y: 32 }, + }), + }, + geometries: [ + { + id: "event-location-marker", + styleId: "marker", + position: center, + properties: { + title: "marker", + }, + }, + ], + }); + } catch (error) { + console.error(error); + setMapLoadingStatus(MapLoadingStatus.Error); + } + }, [isValid, latitude, longitude]); + + useEffect(() => { + if (window.TMap) { + queueMicrotask(initMap); + } + }, [initMap]); + + const handleRetryLoadMap = useCallback(() => { + hasInitializedRef.current = false; + + setMapLoadingStatus(MapLoadingStatus.Loading); + + if (window.TMap) { + queueMicrotask(initMap); + return; + } + + setScriptReloadKey((value) => value + 1); + }, [initMap]); + + if (!isValid) { + return null; + } + + return ( + <> +