diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..03711a98 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 9.0.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Type check + run: pnpm check-types + + - name: Test core package + run: pnpm --filter ground-codes test + + - name: Test standalone package + run: pnpm --filter ground-codes test:standalone + + - name: Test API + run: pnpm --filter api-ground-codes test + + - name: Test web client + run: pnpm --filter web test + + - name: Install Playwright browser + run: pnpm --filter web exec playwright install --with-deps chromium + + - name: Browser smoke test + run: pnpm --filter web test:e2e diff --git a/.github/workflows/deploy-grok-spiral.yml b/.github/workflows/deploy-grok-spiral.yml index 690cec5f..09df61e7 100644 --- a/.github/workflows/deploy-grok-spiral.yml +++ b/.github/workflows/deploy-grok-spiral.yml @@ -6,7 +6,11 @@ on: - main paths: - "apps/grok-spiral/**" + - "packages/ui/**" - ".github/workflows/deploy-grok-spiral.yml" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" workflow_dispatch: jobs: diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 315f0b75..836cc8ac 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -6,7 +6,11 @@ on: - main paths: - "apps/web/**" + - "packages/ui/**" - ".github/workflows/deploy-web.yml" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" workflow_dispatch: jobs: @@ -56,6 +60,12 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: ${{ secrets.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY }} NEXT_PUBLIC_GOOGLE_MAPS_ROADMAP_ID: ${{ secrets.NEXT_PUBLIC_GOOGLE_MAPS_ROADMAP_ID }} + NEXT_PUBLIC_GROUND_CODES_API_URL: ${{ secrets.NEXT_PUBLIC_GROUND_CODES_API_URL }} + NEXT_PUBLIC_CESIUM_ION_TOKEN: ${{ secrets.NEXT_PUBLIC_CESIUM_ION_TOKEN }} + NEXT_PUBLIC_CESIUM_MOON_ASSET_ID: ${{ secrets.NEXT_PUBLIC_CESIUM_MOON_ASSET_ID }} + NEXT_PUBLIC_CESIUM_MARS_ASSET_ID: ${{ secrets.NEXT_PUBLIC_CESIUM_MARS_ASSET_ID }} + GOOGLE_MAPS_NODEJS_API_KEY: ${{ secrets.GOOGLE_MAPS_NODEJS_API_KEY }} + OPENWEATHER_API_KEY: ${{ secrets.OPENWEATHER_API_KEY }} run: | cd apps/web pnpm run pages:build && wrangler pages deploy diff --git a/.github/workflows/production-smoke.yml b/.github/workflows/production-smoke.yml new file mode 100644 index 00000000..22f318bf --- /dev/null +++ b/.github/workflows/production-smoke.yml @@ -0,0 +1,20 @@ +name: Production Smoke + +on: + workflow_dispatch: + schedule: + - cron: "*/30 * * * *" + +jobs: + smoke: + runs-on: ubuntu-latest + + steps: + - name: Check API readiness + run: curl -fsS https://api.ground.codes/readyz + + - name: Check web robots + run: curl -fsS https://ground.codes/robots.txt + + - name: Check web sitemap + run: curl -fsS https://ground.codes/sitemap.xml diff --git a/AGENTS.md b/AGENTS.md index 1c609162..96856820 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,6 +138,7 @@ PR 제출 전 위 4개를 모두 통과시킵니다. - `packages/geoint` 데이터 갱신 시 sample 검증. - Web 앱의 시각 회귀는 수동을 우선합니다. - 지구·달·화성 등 행성 지도 렌더링을 변경한 경우, 배포 후 실제 production URL 에서 이미지 스크린샷을 반드시 확인합니다. 네트워크 요청 성공, canvas 생성 여부, 타입 체크만으로 완료 처리하지 않습니다. +- 행성 지도 UI 변경은 데스크톱과 모바일 폭 모두에서 직접 확인합니다. 특히 좌측 하단 출처 라벨, 하단 인코딩 패널, 우측 하단 컨트롤이 서로 겹치지 않아야 합니다. --- @@ -147,3 +148,9 @@ PR 제출 전 위 4개를 모두 통과시킵니다. - **좌표 정확도**: GCS(Geographic Coordinate System) 기반이므로 부동소수 정밀도 손실에 주의 — high-zoom 영역의 인코딩은 fixed-point 또는 BigInt 처리. - **외부 의존**: Google Maps SDK 변경에 추적이 필요합니다 (Maps Platform 의 deprecation 알림 모니터링). - **행성 지도 품질**: 행성 기본 뷰는 고화질을 우선하며, 각 행성의 실제색에 가까운 자연스러운 색감을 선호합니다. 화질 향상을 위해 적외선·고도·relief 레이어를 사용할 때도 기본 색상이 과도하게 알록달록하거나 실제 행성색에서 벗어나지 않도록 합니다. +- **행성 지도 출처**: 달·화성 3D 지도에서 USGS WMS imagery 를 쓰는 경우, 지도 출처 라벨은 좌측 하단에 유지합니다. Cesium ion asset 을 쓰지 않는 WMS-only 렌더에서는 Cesium ion credit/logo 가 행성 전환이나 카메라 조작 후 다시 노출되지 않아야 합니다. +- **3D 지도 컨트롤**: 달·화성에는 내 위치 버튼을 표시하지 않습니다. 숨김 상태에서도 우측 하단 컨트롤 스택에 빈 슬롯을 남기지 않고, 그리드 버튼과 나침반 버튼이 자연스럽게 붙어 보여야 합니다. +- **3D 나침반 동작**: 지구·달·화성 3D 뷰에서 나침반은 양방향 동기화되어야 합니다. 나침반 드래그는 3D 카메라를 회전해야 하고, 사용자가 구체를 직접 회전하면 나침반도 현재 화면 방향을 따라 회전해야 합니다. 나침반을 단순 클릭하면 북쪽이 화면 위로 오도록 `0deg` 로 정렬합니다. +- **Google Maps 3D 지구**: Earth 3D 는 Google `Map3DElement.heading` 을 부모 나침반 상태와 동기화합니다. 직접 구체를 돌리는 경우 `steadychange` 이벤트와 짧은 polling 으로 heading 변화를 감지합니다. +- **Cesium 달·화성 3D**: Cesium 구체는 `camera.heading` 또는 `camera.setView({ orientation.heading })` 만으로는 화면상 회전이 체감되지 않을 수 있습니다. 나침반 기반 회전은 현재 시야 축 기준 `camera.twistRight()` 에 최단 heading delta 를 적용합니다. 직접 구체 조작 후 나침반 동기화는 화면 중심점에서 실제 북쪽이 화면상 어느 방향을 향하는지 계산해 부모 나침반 상태로 올립니다. +- **Cesium 초기화 주의**: 달·화성 3D 초기 카메라는 북쪽 기준으로 세팅한 뒤 필요한 나침반 heading 만 별도 twist 로 적용합니다. 초기 `setView` heading 과 `twistRight` 를 동시에 같은 heading 으로 적용하면 재마운트 시 회전이 중복될 수 있습니다. diff --git a/apps/api-ground-codes/.env.example b/apps/api-ground-codes/.env.example new file mode 100644 index 00000000..af5ca60a --- /dev/null +++ b/apps/api-ground-codes/.env.example @@ -0,0 +1,3 @@ +PORT=3000 +CORS_ALLOWED_ORIGINS=https://ground.codes +API_RATE_LIMIT_PER_MINUTE=600 diff --git a/apps/api-ground-codes/README.md b/apps/api-ground-codes/README.md index 96597e88..21390478 100644 --- a/apps/api-ground-codes/README.md +++ b/apps/api-ground-codes/README.md @@ -37,8 +37,9 @@ The API uses these optimization techniques in several key endpoints: - `/v1/region/around`: Finds regions near specified coordinates using GeoKDBush spatial indexing - `/v1/region/info`: Retrieves region information using LevelDB's fast key-value lookups -- `/encode`: Utilizes the optimized region search to find the nearest region for encoding coordinates -- `/decode`: Uses efficient region data retrieval when decoding ground codes +- `/v1/encode`: Utilizes the optimized region search to find the nearest region for encoding coordinates +- `/v1/decode`: Uses efficient region data retrieval when decoding ground codes +- `/v1/search`: Resolves encoded Ground Codes, `lat,lng` coordinate pairs, and region names/codes The API uses the shared `ground-codes` package for encode/decode behavior and loads `@ground-codes/geoint` embedded databases for region lookup endpoints. @@ -117,14 +118,17 @@ changes from the same commit. ### 🧩 Core Endpoints -- `POST /encode`: Encode geographic coordinates to a ground code -- `POST /decode`: Decode a ground code to geographic coordinates -- `GET /v1/region/around`: Get regions around specific coordinates -- `GET /v1/region/info`: Get information about a specific region +- `POST /v1/encode`: Encode geographic coordinates to a ground code +- `POST /v1/decode`: Decode a ground code to geographic coordinates +- `POST /v1/search`: Search by encoded ground code, coordinate pair, or region name +- `POST /v1/region/around`: Get regions around specific coordinates +- `POST /v1/region/info`: Get information about a specific region ### 🔧 Utility Endpoints - `GET /healthz`: Health check endpoint +- `GET /readyz`: Readiness endpoint for deploy/load-balancer checks +- `GET /metrics`: Lightweight JSON operational counters - `GET /swagger`: Swagger UI for API documentation - `GET /`: Redirects to Swagger documentation @@ -133,7 +137,7 @@ changes from the same commit. ### 🔄 Encode Coordinates ```bash -curl -X POST http://localhost:3000/encode \ +curl -X POST http://localhost:3000/v1/encode \ -H "Content-Type: application/json" \ -d '{"lat": 37.422, "lng": 127.024, "regionLevel": 2, "language": "english"}' ``` @@ -147,7 +151,7 @@ Response: ### 🔍 Decode Ground Code ```bash -curl -X POST http://localhost:3000/decode \ +curl -X POST http://localhost:3000/v1/decode \ -H "Content-Type: application/json" \ -d '{"code": "Seoul-Happy-Tiger", "regionLevel": 2, "language": "english"}' ``` @@ -164,7 +168,7 @@ Response: ### 🌕 Encode Moon or Mars Coordinates ```bash -curl -X POST http://localhost:3000/encode \ +curl -X POST http://localhost:3000/v1/encode \ -H "Content-Type: application/json" \ -d '{"lat": 8.35, "lng": 30.84, "body": "moon", "regionLevel": 2}' ``` @@ -178,7 +182,7 @@ Response: Japanese labels are also available: ```bash -curl -X POST http://localhost:3000/encode \ +curl -X POST http://localhost:3000/v1/encode \ -H "Content-Type: application/json" \ -d '{"lat": 18.6528, "lng": 226.1975, "body": "mars", "regionLevel": 2, "language": "japanese"}' ``` @@ -190,7 +194,7 @@ Response: ``` ```bash -curl -X POST http://localhost:3000/decode \ +curl -X POST http://localhost:3000/v1/decode \ -H "Content-Type: application/json" \ -d '{"code": "Mare Tranquillitatis-...", "body": "moon", "regionLevel": 2}' ``` @@ -209,6 +213,11 @@ The API supports various configuration options: - 🌐 **Language**: Select from supported languages (English, Korean, Chinese, Japanese) - 📏 **Precision**: Adjust the precision of encoded locations in meters - 🪐 **Body**: Select `earth`, `moon`, or `mars` for coordinate conversion and labels +- 🔐 **CORS**: Set `CORS_ALLOWED_ORIGINS` as a comma-separated production allowlist +- 🛡️ **Rate Limit**: Set `API_RATE_LIMIT_PER_MINUTE`; use `0` only to disable it intentionally + +Search responses include short-lived shared-cache headers so production edges can +cache common lookups without caching mutable operational checks. ## 📚 Documentation diff --git a/apps/api-ground-codes/package.json b/apps/api-ground-codes/package.json index 4b9263f7..6bc8c885 100644 --- a/apps/api-ground-codes/package.json +++ b/apps/api-ground-codes/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "bun run --watch src/index.ts", "build": "node scripts/build.mjs", - "start": "bun run src/index.ts" + "start": "bun run src/index.ts", + "check-types": "tsc --noEmit", + "test": "pnpm --filter ground-codes build && bun test src/*.test.ts" }, "dependencies": { "@elysiajs/cors": "^1.2.0", diff --git a/apps/api-ground-codes/src/app.test.ts b/apps/api-ground-codes/src/app.test.ts new file mode 100644 index 00000000..f9dfd997 --- /dev/null +++ b/apps/api-ground-codes/src/app.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, test } from "bun:test"; +import { createApp } from "./app.js"; + +const app = createApp(); +const rateLimitedApp = createApp({ + rateLimit: { + max: 1, + windowMs: 60_000, + }, +}); + +const postJson = (path: string, body: unknown) => + app.handle( + new Request(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + +const get = (path: string) => + app.handle(new Request(`http://localhost${path}`)); + +describe("Ground Codes API contract", () => { + test("serves a readiness endpoint for deployment checks", async () => { + const response = await get("/readyz"); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + status: "ready", + service: "api-ground-codes", + }); + }); + + test("serves lightweight operational metrics", async () => { + await get("/healthz"); + + const response = await get("/metrics"); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.service).toBe("api-ground-codes"); + expect(body.requests.total).toBeGreaterThan(0); + }); + + test("serves public API documentation without exposing legacy routes", async () => { + const firstPartyDocsResponse = await get("/docs"); + expect(firstPartyDocsResponse.status).toBe(200); + expect(await firstPartyDocsResponse.text()).toContain( + "https://api.ground.codes/v1/encode", + ); + + const docsResponse = await get("/"); + expect(docsResponse.status).toBe(200); + expect(await docsResponse.text()).toContain( + "Ground Codes API Documentation", + ); + + const schemaResponse = await get("/json"); + expect(schemaResponse.status).toBe(200); + const schema = await schemaResponse.json(); + expect(schema.paths["/v1/encode"]).toBeDefined(); + expect(schema.paths["/v1/search"]).toBeDefined(); + expect(schema.paths["/encode"]).toBeUndefined(); + expect(schema.paths["/search"]).toBeUndefined(); + expect(schema.paths["/docs"]).toBeUndefined(); + expect(schema.paths["/{path}"]).toBeUndefined(); + }); + + test("redirects legacy swagger documentation URLs to the public docs", async () => { + const docsResponse = await get("/swagger"); + expect(docsResponse.status).toBe(302); + expect(docsResponse.headers.get("location")).toBe("/"); + + const schemaResponse = await get("/swagger/json"); + expect(schemaResponse.status).toBe(302); + expect(schemaResponse.headers.get("location")).toBe("/json"); + }); + + test( + "serves encode through the versioned v1 route", + async () => { + const response = await postJson("/v1/encode", { + lat: 37.566, + lng: 126.978, + language: "english", + regionLevel: 2, + }); + + expect(response.status).toBe(200); + expect(await response.text()).toMatch(/^Seoul-/); + }, + 90_000, + ); + + test("search resolves an encoded ground code", async () => { + const encodedResponse = await postJson("/v1/encode", { + lat: 37.566, + lng: 126.978, + language: "english", + regionLevel: 2, + }); + const code = await encodedResponse.text(); + + const response = await postJson("/v1/search", { + query: code, + language: "english", + regionLevel: 2, + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.results[0]).toMatchObject({ + type: "ground-code", + label: code, + body: "earth", + regionLevel: 2, + }); + expect(body.results[0].lat).toBeNumber(); + expect(body.results[0].lng).toBeNumber(); + }); + + test("search resolves a region name", async () => { + const response = await postJson("/v1/search", { + query: "Seoul", + language: "english", + regionLevel: 2, + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.results[0]).toMatchObject({ + type: "region", + label: "Seoul", + body: "earth", + regionLevel: 2, + }); + }); + + test("search returns multiple partial region matches with cache headers", async () => { + const response = await postJson("/v1/search", { + query: "Seo", + language: "english", + regionLevel: 2, + maxResults: 3, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toContain("s-maxage"); + const body = await response.json(); + expect(body.results.length).toBeGreaterThan(1); + expect(body.results.length).toBeLessThanOrEqual(3); + expect(body.results.some((result: any) => result.label === "Seoul")).toBe( + true, + ); + }); + + test("search resolves common city aliases", async () => { + const response = await postJson("/v1/search", { + query: "nyc", + language: "english", + regionLevel: 2, + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.results[0]).toMatchObject({ + type: "region", + label: "New York City", + }); + }); + + test( + "search falls back to English region names from a localized request", + async () => { + const response = await postJson("/v1/search", { + query: "Seoul", + language: "korean", + regionLevel: 2, + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.results[0]).toMatchObject({ + type: "region", + label: "Seoul", + body: "earth", + regionLevel: 2, + }); + }, + 90_000, + ); + + test("search detects an English encoded code from a localized request", async () => { + const encodedResponse = await postJson("/v1/encode", { + lat: 37.566, + lng: 126.978, + language: "english", + regionLevel: 2, + }); + const code = await encodedResponse.text(); + + const response = await postJson("/v1/search", { + query: code, + language: "korean", + regionLevel: 2, + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.results[0]).toMatchObject({ + type: "ground-code", + label: code, + body: "earth", + regionLevel: 2, + }); + }); + + test("loads region lookup data on demand for region endpoints", async () => { + const response = await postJson("/v1/region/around", { + lat: 37.566, + lng: 126.978, + language: "english", + regionLevel: 2, + maxResults: 1, + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body[0]).toMatchObject({ + name: "Seoul", + }); + }); + + test("returns a structured client error for invalid coordinates", async () => { + const response = await postJson("/v1/encode", { + lat: 120, + lng: 126.978, + language: "english", + regionLevel: 2, + }); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: { + code: "INVALID_INPUT", + }, + }); + }); + + test("returns a structured rate-limit error when the limit is exceeded", async () => { + const requestBody = { + lat: 120, + lng: 126.978, + language: "english", + regionLevel: 2, + }; + await rateLimitedApp.handle( + new Request("http://localhost/v1/encode", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestBody), + }), + ); + + const response = await rateLimitedApp.handle( + new Request("http://localhost/v1/encode", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestBody), + }), + ); + + expect(response.status).toBe(429); + expect(await response.json()).toMatchObject({ + error: { + code: "RATE_LIMITED", + }, + }); + }); +}); diff --git a/apps/api-ground-codes/src/app.ts b/apps/api-ground-codes/src/app.ts index 5fbcb42a..34bcf3dc 100644 --- a/apps/api-ground-codes/src/app.ts +++ b/apps/api-ground-codes/src/app.ts @@ -1,28 +1,59 @@ import { Elysia } from "elysia"; -import { healthz } from "./endpoints/healthz.js"; -import { swaggerEndpoint } from "./endpoints/swagger.js"; -import { corsEndpoint } from "./endpoints/cors.js"; +import { healthz, readyz } from "./endpoints/healthz.js"; +import { + swaggerEndpoint, + swaggerRedirectEndpoint, +} from "./endpoints/swagger.js"; +import { createCorsEndpoint } from "./endpoints/cors.js"; import { staticPlugin } from "@elysiajs/static"; import { codeEndpoint, rootRedirectEndpoint } from "./endpoints/code.js"; -import { v1Endpoints } from "./endpoints/v1/v1-endpoints.js"; +import { docsEndpoint } from "./endpoints/docs.js"; +import { legacyEndpoints, v1Endpoints } from "./endpoints/v1/v1-endpoints.js"; +import { formatApiError } from "./endpoints/v1/api-error.js"; +import { metricsEndpoint } from "./endpoints/metrics.js"; +import { + createRateLimitEndpoint, + getDefaultRateLimit, + RateLimitOptions, +} from "./endpoints/rate-limit.js"; + +interface AppOptions { + port?: string | number; + rateLimit?: RateLimitOptions | null; + corsOrigins?: string[]; +} /** - * Create an Elysia application instance that listens on the specified port. + * Create an Elysia application instance. * - * @param port The port to listen on. + * @param portOrOptions Optional port or app options. Omit this in tests to use `app.handle()`. * @returns An Elysia application instance. */ -export const createApp = (port: string | number) => - new Elysia() +export const createApp = (portOrOptions?: string | number | AppOptions) => { + const options = + typeof portOrOptions === "object" ? portOrOptions : { port: portOrOptions }; + const rateLimit = + "rateLimit" in options ? options.rateLimit : getDefaultRateLimit(); + + const app = new Elysia() + .onError(({ error, code, set }) => formatApiError(error, code, set)) + .use(createCorsEndpoint(options.corsOrigins)) + .use(createRateLimitEndpoint(rateLimit)) + .use(metricsEndpoint) + .use(swaggerRedirectEndpoint) + .use(docsEndpoint) + .use(swaggerEndpoint) .use( staticPlugin({ prefix: "/", }) ) - .use(corsEndpoint) - .use(swaggerEndpoint) .use(healthz) - .use(codeEndpoint) - .use(rootRedirectEndpoint) + .use(readyz) .use(v1Endpoints) - .listen(port); + .use(legacyEndpoints) + .use(codeEndpoint) + .use(rootRedirectEndpoint); + + return options.port === undefined ? app : app.listen(options.port); +}; diff --git a/apps/api-ground-codes/src/endpoints/cors.ts b/apps/api-ground-codes/src/endpoints/cors.ts index 38e2a261..f50c5166 100644 --- a/apps/api-ground-codes/src/endpoints/cors.ts +++ b/apps/api-ground-codes/src/endpoints/cors.ts @@ -1,19 +1,31 @@ import cors from "@elysiajs/cors"; import Elysia from "elysia"; -export const corsEndpoint = new Elysia() - .onAfterHandle(({ request, set }) => { +export const getAllowedOriginsFromEnv = () => + process.env.CORS_ALLOWED_ORIGINS?.split(",") + .map((origin) => origin.trim()) + .filter(Boolean); + +export const createCorsEndpoint = (allowedOrigins = getAllowedOriginsFromEnv()) => + new Elysia() + .onAfterHandle(({ request, set }) => { // * Only process CORS requests - if (request.method !== "OPTIONS") return; + if (request.method !== "OPTIONS") return; - const allowHeader = set.headers["Access-Control-Allow-Headers"]; - if (allowHeader === "*") { - set.headers["Access-Control-Allow-Headers"] = - request.headers.get("Access-Control-Request-Headers") ?? ""; - } - }) - .use( - cors({ - origin: true, + const allowHeader = set.headers["Access-Control-Allow-Headers"]; + if (allowHeader === "*") { + set.headers["Access-Control-Allow-Headers"] = + request.headers.get("Access-Control-Request-Headers") ?? ""; + } }) - ); + .use( + cors({ + origin: + allowedOrigins && allowedOrigins.length > 0 + ? (request: Request) => { + const origin = request.headers.get("origin"); + return origin ? allowedOrigins.includes(origin) : false; + } + : true, + }), + ); diff --git a/apps/api-ground-codes/src/endpoints/docs.ts b/apps/api-ground-codes/src/endpoints/docs.ts new file mode 100644 index 00000000..19247bba --- /dev/null +++ b/apps/api-ground-codes/src/endpoints/docs.ts @@ -0,0 +1,143 @@ +import Elysia from "elysia"; + +const docsHtml = ` + + + + + Ground Codes API + + + +
+
+

Ground Codes API

+

Encode coordinates into human-readable share codes, decode them back to coordinates, and search regions or codes through stable versioned endpoints.

+ +
+
+
+

Base URL

+

Production clients should call https://api.ground.codes and keep all new integrations on /v1/*.

+
+
+

Share URL Format

+
    +
  • Earth: https://ground.codes/Seoul-word-word
  • +
  • Moon: https://ground.codes/moon/word-word-word
  • +
  • Mars: https://ground.codes/mars/word-word-word
  • +
+
+
+

Encode

+

POST /v1/encode with latitude, longitude, language, body, and optional precision controls.

+
curl -X POST https://api.ground.codes/v1/encode \\
+  -H "Content-Type: application/json" \\
+  -d '{"lat":37.566,"lng":126.978,"language":"english","body":"earth","regionLevel":2}'
+
+
+

Search

+

Search accepts encoded ground codes, partial region names, and common aliases such as nyc.

+
curl -X POST https://api.ground.codes/v1/search \\
+  -H "Content-Type: application/json" \\
+  -d '{"query":"Seoul-happy-river","language":"english","body":"earth","maxResults":5}'
+
+
+

Operational Endpoints

+
    +
  • /healthz liveness
  • +
  • /readyz deployment readiness
  • +
  • /metrics request counts and latency
  • +
+
+
+

Error Shape

+

Client and server errors return a structured {"error":{"code","message","details"}} payload.

+
+
+
+ +`; + +export const docsEndpoint = new Elysia().get( + "/docs", + ({ set }) => { + set.headers["cache-control"] = "public, max-age=300"; + set.headers["content-type"] = "text/html; charset=utf-8"; + return docsHtml; + }, + { + detail: { + hide: true, + }, + }, +); diff --git a/apps/api-ground-codes/src/endpoints/healthz.ts b/apps/api-ground-codes/src/endpoints/healthz.ts index 27fc271a..c1e50c7d 100644 --- a/apps/api-ground-codes/src/endpoints/healthz.ts +++ b/apps/api-ground-codes/src/endpoints/healthz.ts @@ -17,3 +17,30 @@ export const healthz = new Elysia().get( }), } ); + +export const readyz = new Elysia().get( + "/readyz", + async ({ set }) => { + set.headers["cache-control"] = "no-store"; + + return { + status: "ready", + service: "api-ground-codes", + }; + }, + { + detail: { + tags: ["Health"], + summary: "Readiness Check", + description: "Deployment readiness endpoint", + }, + response: t.Object({ + status: t.String({ + example: "ready", + }), + service: t.String({ + example: "api-ground-codes", + }), + }), + } +); diff --git a/apps/api-ground-codes/src/endpoints/metrics.ts b/apps/api-ground-codes/src/endpoints/metrics.ts new file mode 100644 index 00000000..4924cdf8 --- /dev/null +++ b/apps/api-ground-codes/src/endpoints/metrics.ts @@ -0,0 +1,123 @@ +import Elysia from "elysia"; +import { getRegionLoadMetrics } from "./v1/region/load-region.js"; + +interface PathMetrics { + count: number; + totalMs: number; + minMs: number; + maxMs: number; + byStatus: Record; +} + +interface RequestMetricsSnapshot { + startedAt: string; + total: number; + totalMs: number; + byPath: Record; + routes: Record; +} + +const requestMetrics: RequestMetricsSnapshot = { + startedAt: new Date().toISOString(), + total: 0, + totalMs: 0, + byPath: {}, + routes: {}, +}; + +const requestStartTimes = new WeakMap(); + +const getStatusCode = (status: unknown): string => { + if (typeof status === "number") return String(status); + if (typeof status === "string" && status.length > 0) return status; + return "200"; +}; + +const recordRequest = ( + request: Request, + status: unknown, + durationMs: number, +) => { + const pathname = new URL(request.url).pathname; + if (pathname === "/metrics") return; + + const roundedDurationMs = Math.max(0, Math.round(durationMs * 100) / 100); + const statusCode = getStatusCode(status); + + requestMetrics.total += 1; + requestMetrics.totalMs += roundedDurationMs; + requestMetrics.byPath[pathname] = (requestMetrics.byPath[pathname] ?? 0) + 1; + + const routeMetrics = + requestMetrics.routes[pathname] ?? + (requestMetrics.routes[pathname] = { + count: 0, + totalMs: 0, + minMs: Number.POSITIVE_INFINITY, + maxMs: 0, + byStatus: {}, + }); + + routeMetrics.count += 1; + routeMetrics.totalMs += roundedDurationMs; + routeMetrics.minMs = Math.min(routeMetrics.minMs, roundedDurationMs); + routeMetrics.maxMs = Math.max(routeMetrics.maxMs, roundedDurationMs); + routeMetrics.byStatus[statusCode] = + (routeMetrics.byStatus[statusCode] ?? 0) + 1; +}; + +const serializeRoutes = () => + Object.fromEntries( + Object.entries(requestMetrics.routes).map(([path, routeMetrics]) => [ + path, + { + count: routeMetrics.count, + avgMs: + routeMetrics.count === 0 + ? 0 + : Math.round((routeMetrics.totalMs / routeMetrics.count) * 100) / + 100, + minMs: + routeMetrics.minMs === Number.POSITIVE_INFINITY + ? 0 + : routeMetrics.minMs, + maxMs: routeMetrics.maxMs, + byStatus: routeMetrics.byStatus, + }, + ]), + ); + +export const metricsEndpoint = new Elysia() + .onRequest(({ request }) => { + requestStartTimes.set(request, performance.now()); + }) + .onAfterHandle({ as: "global" }, ({ request, set }) => { + const startedAt = requestStartTimes.get(request) ?? performance.now(); + recordRequest(request, set.status, performance.now() - startedAt); + }) + .onError({ as: "global" }, ({ request, set, code }) => { + const startedAt = requestStartTimes.get(request) ?? performance.now(); + recordRequest(request, set.status ?? code, performance.now() - startedAt); + }) + .get("/metrics", ({ set }) => { + set.headers["cache-control"] = "no-store"; + + return { + service: "api-ground-codes", + startedAt: requestMetrics.startedAt, + uptimeSeconds: Math.round( + (Date.now() - Date.parse(requestMetrics.startedAt)) / 1000, + ), + requests: { + total: requestMetrics.total, + avgMs: + requestMetrics.total === 0 + ? 0 + : Math.round((requestMetrics.totalMs / requestMetrics.total) * 100) / + 100, + byPath: requestMetrics.byPath, + routes: serializeRoutes(), + }, + regionLoads: getRegionLoadMetrics(), + }; + }); diff --git a/apps/api-ground-codes/src/endpoints/rate-limit.ts b/apps/api-ground-codes/src/endpoints/rate-limit.ts new file mode 100644 index 00000000..d5e094d6 --- /dev/null +++ b/apps/api-ground-codes/src/endpoints/rate-limit.ts @@ -0,0 +1,72 @@ +import Elysia from "elysia"; + +export interface RateLimitOptions { + max: number; + windowMs: number; +} + +interface Bucket { + count: number; + resetAt: number; +} + +const getClientKey = (request: Request) => + request.headers.get("cf-connecting-ip") ?? + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + "local"; + +export const getDefaultRateLimit = (): RateLimitOptions | null => { + const rawLimit = process.env.API_RATE_LIMIT_PER_MINUTE; + const max = rawLimit === undefined ? 600 : Number(rawLimit); + + if (!Number.isFinite(max) || max <= 0) return null; + return { + max, + windowMs: 60_000, + }; +}; + +export const createRateLimitEndpoint = ( + options: RateLimitOptions | null = getDefaultRateLimit(), +) => { + const buckets = new Map(); + + return new Elysia().onBeforeHandle({ as: "global" }, ({ request, set }) => { + if (!options || request.method === "OPTIONS") return; + + const pathname = new URL(request.url).pathname; + if (pathname === "/healthz") return; + + const now = Date.now(); + const key = getClientKey(request); + const current = buckets.get(key); + const bucket = + !current || current.resetAt <= now + ? { count: 0, resetAt: now + options.windowMs } + : current; + + bucket.count += 1; + buckets.set(key, bucket); + + const retryAfterSeconds = Math.max( + 1, + Math.ceil((bucket.resetAt - now) / 1000), + ); + set.headers["X-RateLimit-Limit"] = String(options.max); + set.headers["X-RateLimit-Remaining"] = String( + Math.max(0, options.max - bucket.count), + ); + set.headers["X-RateLimit-Reset"] = String(bucket.resetAt); + + if (bucket.count > options.max) { + set.status = 429; + set.headers["Retry-After"] = String(retryAfterSeconds); + return { + error: { + code: "RATE_LIMITED", + message: "Too many requests. Try again later.", + }, + }; + } + }); +}; diff --git a/apps/api-ground-codes/src/endpoints/swagger.ts b/apps/api-ground-codes/src/endpoints/swagger.ts index f8f3b24e..ddd1cea7 100644 --- a/apps/api-ground-codes/src/endpoints/swagger.ts +++ b/apps/api-ground-codes/src/endpoints/swagger.ts @@ -1,18 +1,58 @@ import swagger from "@elysiajs/swagger"; +import Elysia, { redirect } from "elysia"; + +const publicDocPathsToHide = [ + "/json", + "/encode", + "/decode", + "/search", + "/docs", + "/region/around", + "/region/info", + "/{path}", +]; + +const scalarReferenceConfig = { + theme: "mars" as const, + darkMode: true, + forceDarkModeState: "dark" as const, + hideClientButton: true, + hideDarkModeToggle: true, + hideModels: true, + hiddenClients: true as const, + defaultHttpClient: { + targetKey: "shell" as const, + clientKey: "curl", + }, + customCss: ` + .scalar-mcp-layer, + .scalar-mcp-layer-link, + .gitbook-show.scalar-version-number, + .references-developer-tools, + .agent-button-container, + button[id^="headlessui-popover-button-scalar-refs-"], + button.bg-sidebar-b-search.whitespace-nowrap, + a[href*="scalar.com"], + [aria-label="Deploy"], + [aria-label="Share"], + [aria-label="Configure"], + [aria-label="Developer Tools"], + [aria-label="Ask AI"] { + display: none !important; + } + `, + favicon: "/favicon.ico", +}; export const swaggerEndpoint = swagger({ path: "/", - exclude: ["/json"], - scalarConfig: { - theme: "mars", - darkMode: true, - customCss: ``, - favicon: "/favicon.ico", - }, + exclude: publicDocPathsToHide, + scalarConfig: scalarReferenceConfig, documentation: { info: { title: "Ground Codes API Documentation", - description: "API documentation for Ground Codes SaaS", + description: + "Production API documentation for Ground Codes. Use the versioned `/v1/*` endpoints for new integrations. Quick start: POST `/v1/encode` with `{ \"lat\": 37.566, \"lng\": 126.978, \"language\": \"english\", \"regionLevel\": 2 }`, then POST `/v1/search` with the returned code or share it as `https://ground.codes/{encoded-code}`. Earth share URLs are code-only; Moon and Mars use `/moon/{encoded-code}` and `/mars/{encoded-code}`.", version: "1.0.0", }, tags: [ @@ -34,3 +74,32 @@ export const swaggerEndpoint = swagger({ ], }, }); + +export const swaggerRedirectEndpoint = new Elysia() + .get( + "/swagger", + () => redirect("/"), + { + detail: { + hide: true, + }, + }, + ) + .get( + "/swagger/", + () => redirect("/"), + { + detail: { + hide: true, + }, + }, + ) + .get( + "/swagger/json", + () => redirect("/json"), + { + detail: { + hide: true, + }, + }, + ); diff --git a/apps/api-ground-codes/src/endpoints/v1/api-error.ts b/apps/api-ground-codes/src/endpoints/v1/api-error.ts new file mode 100644 index 00000000..e8dbbfff --- /dev/null +++ b/apps/api-ground-codes/src/endpoints/v1/api-error.ts @@ -0,0 +1,43 @@ +export class ApiInputError extends Error { + code = "INVALID_INPUT"; + status = 400; + + constructor(message: string) { + super(message); + this.name = "ApiInputError"; + } +} + +export const formatApiError = ( + error: unknown, + code: string | number, + set: { status?: number | string }, +) => { + if (error instanceof ApiInputError) { + set.status = error.status; + return { + error: { + code: error.code, + message: error.message, + }, + }; + } + + if (code === "VALIDATION") { + set.status = 400; + return { + error: { + code: "INVALID_INPUT", + message: "Request body does not match the API schema.", + }, + }; + } + + set.status = 500; + return { + error: { + code: "INTERNAL_ERROR", + message: "Unexpected API error.", + }, + }; +}; diff --git a/apps/api-ground-codes/src/endpoints/v1/decode.ts b/apps/api-ground-codes/src/endpoints/v1/decode.ts index a2a132e2..12ebda36 100644 --- a/apps/api-ground-codes/src/endpoints/v1/decode.ts +++ b/apps/api-ground-codes/src/endpoints/v1/decode.ts @@ -1,6 +1,12 @@ import Elysia, { t } from "elysia"; import { CelestialBody, decode, SupportedLanguage } from "ground-codes"; import { supportedLanguages } from "./language.js"; +import { + validateBody, + validateLanguage, + validateRegionLevel, + validateSearchQuery, +} from "./validation.js"; export const v1Decode = new Elysia().post( "/decode", @@ -12,10 +18,15 @@ export const v1Decode = new Elysia().post( body: celestialBody = "earth", }, }) => { + const query = validateSearchQuery(code); + const validatedLanguage = validateLanguage(language); + const validatedBody = validateBody(celestialBody); + validateRegionLevel({ body: validatedBody, regionLevel }); + return await decode(code, { regionLevel, - language: language as SupportedLanguage, - body: celestialBody as CelestialBody, + language: validatedLanguage as SupportedLanguage, + body: validatedBody as CelestialBody, }); }, { diff --git a/apps/api-ground-codes/src/endpoints/v1/encode.ts b/apps/api-ground-codes/src/endpoints/v1/encode.ts index c2e4c6e1..f86e239c 100644 --- a/apps/api-ground-codes/src/endpoints/v1/encode.ts +++ b/apps/api-ground-codes/src/endpoints/v1/encode.ts @@ -1,6 +1,13 @@ import Elysia, { t } from "elysia"; import { CelestialBody, encode, SupportedLanguage } from "ground-codes"; import { supportedLanguages } from "./language.js"; +import { + validateBody, + validateCoordinates, + validateLanguage, + validatePrecisionMeters, + validateRegionLevel, +} from "./validation.js"; export const v1Encode = new Elysia().post( "/encode", @@ -14,6 +21,12 @@ export const v1Encode = new Elysia().post( body: celestialBody = "earth", }, }) => { + validateCoordinates({ lat, lng }); + const validatedLanguage = validateLanguage(language); + const validatedBody = validateBody(celestialBody); + validateRegionLevel({ body: validatedBody, regionLevel }); + validatePrecisionMeters(precisionMeters); + const encodeOptions: { regionLevel: number; language: SupportedLanguage; @@ -21,8 +34,8 @@ export const v1Encode = new Elysia().post( body: CelestialBody; } = { regionLevel, - language: language as SupportedLanguage, - body: celestialBody as CelestialBody, + language: validatedLanguage as SupportedLanguage, + body: validatedBody as CelestialBody, }; if (precisionMeters !== undefined) { diff --git a/apps/api-ground-codes/src/endpoints/v1/region/around.ts b/apps/api-ground-codes/src/endpoints/v1/region/around.ts index b8ee73a3..7a23e893 100644 --- a/apps/api-ground-codes/src/endpoints/v1/region/around.ts +++ b/apps/api-ground-codes/src/endpoints/v1/region/around.ts @@ -1,6 +1,14 @@ import Elysia, { t } from "elysia"; import { around } from "@ground-codes/geoint"; import { getRegionDatasetName, supportedLanguages } from "../language.js"; +import { + validateBody, + validateCoordinates, + validateLanguage, + validateMaxResults, + validateRegionLevel, +} from "../validation.js"; +import { loadRegionDataset } from "./load-region.js"; export const v1RegionAround = new Elysia().post( "/region/around", @@ -15,11 +23,18 @@ export const v1RegionAround = new Elysia().post( body = "earth", }, }) => { + validateCoordinates({ lat, lng }); + const validatedLanguage = validateLanguage(language); + const validatedBody = validateBody(body); + validateRegionLevel({ body: validatedBody, regionLevel }); + validateMaxResults(maxResults); + const regionName = getRegionDatasetName({ - body, - language, + body: validatedBody, + language: validatedLanguage, regionLevel, }); + await loadRegionDataset(regionName); return ( await around({ diff --git a/apps/api-ground-codes/src/endpoints/v1/region/info.ts b/apps/api-ground-codes/src/endpoints/v1/region/info.ts index d09010bd..f3ad244a 100644 --- a/apps/api-ground-codes/src/endpoints/v1/region/info.ts +++ b/apps/api-ground-codes/src/endpoints/v1/region/info.ts @@ -1,20 +1,33 @@ import Elysia, { t } from "elysia"; import { info } from "@ground-codes/geoint"; import { getRegionDatasetName, supportedLanguages } from "../language.js"; +import { + validateBody, + validateLanguage, + validateRegionLevel, + validateSearchQuery, +} from "../validation.js"; +import { loadRegionDataset } from "./load-region.js"; export const v1RegionInfo = new Elysia().post( "/region/info", async ({ body: { name, language = "english", regionLevel = 2, body = "earth" }, }) => { + const query = validateSearchQuery(name); + const validatedLanguage = validateLanguage(language); + const validatedBody = validateBody(body); + validateRegionLevel({ body: validatedBody, regionLevel }); + const regionName = getRegionDatasetName({ - body, - language, + body: validatedBody, + language: validatedLanguage, regionLevel, }); + await loadRegionDataset(regionName); const data = await info({ - name, + name: query, regionName, }); diff --git a/apps/api-ground-codes/src/endpoints/v1/region/load-region.ts b/apps/api-ground-codes/src/endpoints/v1/region/load-region.ts new file mode 100644 index 00000000..40195fd7 --- /dev/null +++ b/apps/api-ground-codes/src/endpoints/v1/region/load-region.ts @@ -0,0 +1,73 @@ +import { load } from "@ground-codes/geoint"; + +const pendingLoads = new Map>(); +const regionLoadMetrics = { + started: 0, + completed: 0, + failed: 0, + deduped: 0, + lastLoadedAt: null as string | null, + byRegion: {} as Record< + string, + { + started: number; + completed: number; + failed: number; + deduped: number; + lastDurationMs: number | null; + lastLoadedAt: string | null; + } + >, +}; + +const getRegionMetrics = (regionName: string) => + (regionLoadMetrics.byRegion[regionName] ??= { + started: 0, + completed: 0, + failed: 0, + deduped: 0, + lastDurationMs: null, + lastLoadedAt: null, + }); + +export const loadRegionDataset = async (regionName: string) => { + let pendingLoad = pendingLoads.get(regionName); + + if (!pendingLoad) { + const startedAt = performance.now(); + const regionMetrics = getRegionMetrics(regionName); + + regionLoadMetrics.started += 1; + regionMetrics.started += 1; + + pendingLoad = load([regionName]) + .then(() => { + const loadedAt = new Date().toISOString(); + const durationMs = Math.round((performance.now() - startedAt) * 100) / 100; + + regionLoadMetrics.completed += 1; + regionLoadMetrics.lastLoadedAt = loadedAt; + regionMetrics.completed += 1; + regionMetrics.lastDurationMs = durationMs; + regionMetrics.lastLoadedAt = loadedAt; + }) + .catch((error) => { + regionLoadMetrics.failed += 1; + regionMetrics.failed += 1; + throw error; + }) + .finally(() => pendingLoads.delete(regionName)); + pendingLoads.set(regionName, pendingLoad); + } else { + regionLoadMetrics.deduped += 1; + getRegionMetrics(regionName).deduped += 1; + } + + await pendingLoad; +}; + +export const getRegionLoadMetrics = () => ({ + ...regionLoadMetrics, + inFlight: pendingLoads.size, + byRegion: { ...regionLoadMetrics.byRegion }, +}); diff --git a/apps/api-ground-codes/src/endpoints/v1/search.ts b/apps/api-ground-codes/src/endpoints/v1/search.ts new file mode 100644 index 00000000..c9bc81c1 --- /dev/null +++ b/apps/api-ground-codes/src/endpoints/v1/search.ts @@ -0,0 +1,214 @@ +import Elysia, { t } from "elysia"; +import { + CelestialBody, + decode, + encode, + findRegionsByQuery, + SupportedLanguage, +} from "ground-codes"; +import { supportedLanguages } from "./language.js"; +import { + validateBody, + validateCoordinates, + validateLanguage, + validateMaxResults, + validateRegionLevel, + validateSearchQuery, +} from "./validation.js"; + +const coordinatePattern = + /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/; + +const parseCoordinates = (query: string) => { + const match = query.match(coordinatePattern); + if (!match) return null; + const lat = Number(match[1]); + const lng = Number(match[2]); + validateCoordinates({ lat, lng }); + return { lat, lng }; +}; + +const getDecodeLanguages = (language: string) => [ + language, + ...supportedLanguages.filter((candidate) => candidate !== language), +]; + +const englishLikePattern = /^[\x00-\x7F]+$/; +const searchAliases: Record = { + la: "Los Angeles", + lax: "Los Angeles", + nyc: "New York City", + sf: "San Francisco", + sfo: "San Francisco", +}; + +const resolveSearchAlias = (query: string) => + searchAliases[query.toLowerCase()] ?? query; + +const getRegionSearchLanguages = (language: string, query: string) => { + if (language === "english") return ["english"]; + if (englishLikePattern.test(query)) return ["english", language]; + return [language, "english"]; +}; + +export const v1Search = new Elysia().post( + "/search", + async ({ + body: { + query, + regionLevel = 2, + language = "english", + body: celestialBody = "earth", + maxResults = 5, + }, + set, + }) => { + const normalizedQuery = validateSearchQuery(query); + const validatedLanguage = validateLanguage(language); + const validatedBody = validateBody(celestialBody); + validateRegionLevel({ body: validatedBody, regionLevel }); + validateMaxResults(maxResults); + set.headers["cache-control"] = "public, max-age=60, s-maxage=600"; + + const baseResult = { + body: validatedBody, + regionLevel, + }; + + const resolvedQuery = resolveSearchAlias(normalizedQuery); + const coordinates = parseCoordinates(resolvedQuery); + if (coordinates) { + const encoded = await encode(coordinates, { + regionLevel, + language: validatedLanguage as SupportedLanguage, + body: validatedBody as CelestialBody, + }); + + return { + query: normalizedQuery, + results: [ + { + type: "coordinates", + label: encoded, + lat: coordinates.lat, + lng: coordinates.lng, + code: encoded, + ...baseResult, + }, + ], + }; + } + + if (resolvedQuery.includes("-")) { + for (const candidateLanguage of getDecodeLanguages(validatedLanguage)) { + try { + const decoded = await decode(resolvedQuery, { + regionLevel, + language: candidateLanguage as SupportedLanguage, + body: validatedBody as CelestialBody, + }); + + return { + query: normalizedQuery, + results: [ + { + type: "ground-code", + label: resolvedQuery, + lat: decoded.lat, + lng: decoded.lng, + code: resolvedQuery, + ...baseResult, + }, + ], + }; + } catch { + // Fall through to other languages and then region-name search. + } + } + } + + const regions: Awaited> = []; + for (const candidateLanguage of getRegionSearchLanguages( + validatedLanguage, + resolvedQuery, + )) { + const candidateRegions = await findRegionsByQuery(resolvedQuery, { + regionLevel, + language: candidateLanguage as SupportedLanguage, + body: validatedBody as CelestialBody, + maxResults: maxResults - regions.length, + }); + regions.push(...candidateRegions); + if (regions.length >= maxResults) break; + } + + return { + query: normalizedQuery, + results: regions.map((region) => ({ + type: "region", + label: region.name ?? normalizedQuery, + lat: region.lat, + lng: region.lng, + code: region.code, + body: region.body ?? validatedBody, + regionLevel: region.regionLevel ?? regionLevel, + })), + }; + }, + { + detail: { + tags: ["Code"], + summary: "Search ground codes, coordinates, or region names", + description: + "Search an encoded Ground Code, a `lat,lng` coordinate pair, or a known region name/code.", + }, + body: t.Object({ + query: t.String({ + example: "Seoul-Happy-Tiger", + description: "Ground Code, coordinate pair, region name, or region code", + }), + regionLevel: t.Optional( + t.Number({ + default: 2, + example: 2, + }), + ), + language: t.Optional( + t.String({ + default: "english", + example: "english", + enum: supportedLanguages, + }), + ), + body: t.Optional( + t.String({ + default: "earth", + example: "earth", + enum: ["earth", "moon", "mars"], + }), + ), + maxResults: t.Optional( + t.Number({ + default: 5, + minimum: 1, + maximum: 25, + example: 5, + }), + ), + }), + response: t.Object({ + query: t.String(), + results: t.Array( + t.Object({ + type: t.String(), + label: t.String(), + lat: t.Number(), + lng: t.Number(), + code: t.Optional(t.String()), + body: t.String(), + regionLevel: t.Number(), + }), + ), + }), + }, +); diff --git a/apps/api-ground-codes/src/endpoints/v1/v1-endpoints.ts b/apps/api-ground-codes/src/endpoints/v1/v1-endpoints.ts index f6bcdc13..e273b222 100644 --- a/apps/api-ground-codes/src/endpoints/v1/v1-endpoints.ts +++ b/apps/api-ground-codes/src/endpoints/v1/v1-endpoints.ts @@ -3,9 +3,16 @@ import { v1Encode } from "./encode"; import { v1Decode } from "./decode"; import { v1RegionAround } from "./region/around"; import { v1RegionInfo } from "./region/info"; +import { v1Search } from "./search"; -export const v1Endpoints = new Elysia() +const endpointGroup = () => + new Elysia() .use(v1Encode) .use(v1Decode) + .use(v1Search) .use(v1RegionAround) .use(v1RegionInfo); + +export const v1Endpoints = new Elysia({ prefix: "/v1" }).use(endpointGroup()); + +export const legacyEndpoints = endpointGroup(); diff --git a/apps/api-ground-codes/src/endpoints/v1/validation.ts b/apps/api-ground-codes/src/endpoints/v1/validation.ts new file mode 100644 index 00000000..54d9c718 --- /dev/null +++ b/apps/api-ground-codes/src/endpoints/v1/validation.ts @@ -0,0 +1,83 @@ +import { ApiInputError } from "./api-error.js"; +import { supportedLanguages } from "./language.js"; + +const supportedBodies = ["earth", "moon", "mars"] as const; + +export type ApiLanguage = (typeof supportedLanguages)[number]; +export type ApiBody = (typeof supportedBodies)[number]; + +const isFiniteNumber = (value: number) => Number.isFinite(value); + +export const validateLanguage = (language: string): ApiLanguage => { + if (supportedLanguages.includes(language as ApiLanguage)) { + return language as ApiLanguage; + } + + throw new ApiInputError( + `Unsupported language "${language}". Use one of: ${supportedLanguages.join(", ")}.`, + ); +}; + +export const validateBody = (body: string): ApiBody => { + if (supportedBodies.includes(body as ApiBody)) return body as ApiBody; + + throw new ApiInputError( + `Unsupported body "${body}". Use one of: ${supportedBodies.join(", ")}.`, + ); +}; + +export const validateCoordinates = ({ + lat, + lng, +}: { + lat: number; + lng: number; +}) => { + if (!isFiniteNumber(lat) || lat < -90 || lat > 90) { + throw new ApiInputError("Latitude must be a finite number between -90 and 90."); + } + + if (!isFiniteNumber(lng) || lng < -360 || lng > 360) { + throw new ApiInputError( + "Longitude must be a finite number between -360 and 360.", + ); + } +}; + +export const validatePrecisionMeters = (precisionMeters?: number) => { + if (precisionMeters === undefined) return; + if (!isFiniteNumber(precisionMeters) || precisionMeters <= 0) { + throw new ApiInputError("precisionMeters must be a positive number."); + } +}; + +export const validateRegionLevel = ({ + body, + regionLevel, +}: { + body: ApiBody; + regionLevel: number; +}) => { + const allowed = + body === "moon" ? [2] : body === "mars" ? [2, 3] : [1, 2, 3]; + if (!Number.isInteger(regionLevel) || !allowed.includes(regionLevel)) { + throw new ApiInputError( + `${body} supports regionLevel values: ${allowed.join(", ")}.`, + ); + } +}; + +export const validateMaxResults = (maxResults: number) => { + if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 100) { + throw new ApiInputError("maxResults must be an integer between 1 and 100."); + } +}; + +export const validateSearchQuery = (query: string) => { + const trimmed = query.trim(); + if (!trimmed) throw new ApiInputError("query is required."); + if (trimmed.length > 160) { + throw new ApiInputError("query must be 160 characters or fewer."); + } + return trimmed; +}; diff --git a/apps/api-ground-codes/src/index.ts b/apps/api-ground-codes/src/index.ts index 6063a919..552d9b4f 100644 --- a/apps/api-ground-codes/src/index.ts +++ b/apps/api-ground-codes/src/index.ts @@ -1,5 +1,4 @@ import { createApp } from "./app.js"; -import { load } from "@ground-codes/geoint"; void (async function () { console.clear(); @@ -7,9 +6,6 @@ void (async function () { // * Print initialization message console.log("🚀 Initializing Ground Codes API server..."); - // * Load region data - await load(); - // * Create the app const app = createApp(process.env.PORT ?? 3000); diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 00000000..e555631e --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,8 @@ +NEXT_PUBLIC_GROUND_CODES_API_URL=https://api.ground.codes +NEXT_PUBLIC_GOOGLE_MAPS_API_KEY= +NEXT_PUBLIC_GOOGLE_MAPS_ROADMAP_ID= +GOOGLE_MAPS_NODEJS_API_KEY= +OPENWEATHER_API_KEY= +NEXT_PUBLIC_CESIUM_ION_TOKEN= +NEXT_PUBLIC_CESIUM_MOON_ASSET_ID= +NEXT_PUBLIC_CESIUM_MARS_ASSET_ID= diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 5ef6a520..7b8da95f 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/apps/web/README.md b/apps/web/README.md index 331c6d10..b713a576 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -64,7 +64,10 @@ Ground.codes web application is an interactive map service utilizing the Google - 🌓 Improved theme to fix visibility issues of buildings in dark theme - 🌐 Implemented multilingual display of place types - 🌕 Added Earth/Moon/Mars map switching with direct URL support -- 🗺️ Added Moon/Mars default planetary basemaps + - 🗺️ Added Moon/Mars default planetary basemaps + - 🔎 Added Ground Code and region-name search through the API + - 🔗 Added copy/share actions for selected Ground Codes + - 🧭 Added a no-key fallback surface so local/dev builds do not show Google Maps error modals ### 🪐 Planetary Map Mode @@ -94,6 +97,15 @@ Mars, with grid degree spacing adjusted for each body's radius. Ground code region prefixes and word payloads are available in English, Korean, Chinese, and Japanese. +Share URLs use encoded Ground Codes as the canonical address: + +- Earth: `/서울-안방` +- Moon: `/moon/Mare%20Tranquillitatis-...` +- Mars: `/mars/Olympus%20Mons-...` + +Query-string URLs remain useful for development and deep map state, but the app +share action emits the canonical code-only URL. + Moon and Mars also include an experimental Cesium 3D globe mode. Without Cesium ion configuration it falls back to a 3D ellipsoid with the current USGS imagery. For real Moon/Mars terrain tiles, configure a Cesium ion token and the relevant @@ -119,6 +131,7 @@ The project requires the following environment variables to be set in a `.env.lo ``` # Required +NEXT_PUBLIC_GROUND_CODES_API_URL=https://api.ground.codes NEXT_PUBLIC_GOOGLE_MAPS_API_KEY= # Optional @@ -130,6 +143,11 @@ GOOGLE_MAPS_NODEJS_API_KEY= OPENWEATHER_API_KEY= ``` +The map still supports Ground Code search when Google Maps credentials are not +configured. Weather and air-quality widgets are optional; if their server-side +keys are missing, the widget is hidden instead of surfacing a user-facing API +error. + ### 🔐 How to obtain API keys: - **NEXT_PUBLIC_GOOGLE_MAPS_API_KEY** (Required): diff --git a/apps/web/app/[...share]/page.tsx b/apps/web/app/[...share]/page.tsx new file mode 100644 index 00000000..6fc90fd0 --- /dev/null +++ b/apps/web/app/[...share]/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import GoogleMap from "@/components/google-map"; + +export default function SharedCodePage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/api/weather-data/route.test.ts b/apps/web/app/api/weather-data/route.test.ts new file mode 100644 index 00000000..8ca1d131 --- /dev/null +++ b/apps/web/app/api/weather-data/route.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { NextRequest } from "next/server"; +import { POST } from "./route"; + +const originalGoogleKey = process.env.GOOGLE_MAPS_NODEJS_API_KEY; +const originalOpenWeatherKey = process.env.OPENWEATHER_API_KEY; + +afterEach(() => { + if (originalGoogleKey === undefined) { + delete process.env.GOOGLE_MAPS_NODEJS_API_KEY; + } else { + process.env.GOOGLE_MAPS_NODEJS_API_KEY = originalGoogleKey; + } + + if (originalOpenWeatherKey === undefined) { + delete process.env.OPENWEATHER_API_KEY; + } else { + process.env.OPENWEATHER_API_KEY = originalOpenWeatherKey; + } +}); + +describe("weather data API", () => { + test("reports missing optional API keys without a server error", async () => { + delete process.env.GOOGLE_MAPS_NODEJS_API_KEY; + delete process.env.OPENWEATHER_API_KEY; + + const response = await POST( + new NextRequest("http://localhost/api/weather-data", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + lat: 37.566, + lng: 126.978, + language: "ko", + }), + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + airQuality: null, + weather: null, + unavailable: true, + }); + }); + + test("accepts zero latitude and longitude as valid coordinates", async () => { + delete process.env.GOOGLE_MAPS_NODEJS_API_KEY; + delete process.env.OPENWEATHER_API_KEY; + + const response = await POST( + new NextRequest("http://localhost/api/weather-data", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + lat: 0, + lng: 0, + language: "en", + }), + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + unavailable: true, + }); + }); +}); diff --git a/apps/web/app/api/weather-data/route.ts b/apps/web/app/api/weather-data/route.ts index 7ca42f78..d1df9a05 100644 --- a/apps/web/app/api/weather-data/route.ts +++ b/apps/web/app/api/weather-data/route.ts @@ -62,8 +62,14 @@ export interface CombinedWeatherData { airQuality: AirQualityData | null; weather: WeatherData | null; error?: string; + unavailable?: boolean; } +type WeatherEnvKey = "GOOGLE_MAPS_NODEJS_API_KEY" | "OPENWEATHER_API_KEY"; + +const getOptionalWeatherEnv = (key: WeatherEnvKey): string | undefined => + process.env[key]; + /** * API route to fetch both air quality and weather data * This keeps the API keys secure on the server side @@ -73,7 +79,7 @@ export async function POST(request: NextRequest) { const body = await request.json(); const { lat, lng, language = "en" } = body; - if (!lat || !lng) { + if (!Number.isFinite(lat) || !Number.isFinite(lng)) { return NextResponse.json( { error: "Latitude and longitude are required" }, { status: 400 } @@ -81,15 +87,15 @@ export async function POST(request: NextRequest) { } // Access API keys securely from server environment - const googleMapsApiKey = process.env.GOOGLE_MAPS_NODEJS_API_KEY; - const openWeatherApiKey = process.env.OPENWEATHER_API_KEY; + const googleMapsApiKey = getOptionalWeatherEnv("GOOGLE_MAPS_NODEJS_API_KEY"); + const openWeatherApiKey = getOptionalWeatherEnv("OPENWEATHER_API_KEY"); if (!googleMapsApiKey || !openWeatherApiKey) { - console.error("API keys are missing"); - return NextResponse.json( - { error: "API configuration error" }, - { status: 500 } - ); + return NextResponse.json({ + airQuality: null, + weather: null, + unavailable: true, + }); } // Map the language code to supported languages for each API diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 1601bbf4..7cef1cac 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -15,7 +15,26 @@ const spaceMono = Space_Mono({ export const metadata: Metadata = { title: "Ground Codes", - description: "Ground code come to exact earth (+mars) address", + description: + "Share precise Earth, Moon, and Mars locations with short memorable Ground Codes.", + metadataBase: new URL("https://ground.codes"), + openGraph: { + title: "Ground Codes", + description: + "Search, copy, and share precise locations as short memorable codes.", + url: "https://ground.codes", + siteName: "Ground Codes", + type: "website", + }, + twitter: { + card: "summary", + title: "Ground Codes", + description: + "Search, copy, and share precise locations as short memorable codes.", + }, + alternates: { + canonical: "https://ground.codes", + }, }; export const viewport: Viewport = { diff --git a/apps/web/app/robots.ts b/apps/web/app/robots.ts new file mode 100644 index 00000000..b2e85d0e --- /dev/null +++ b/apps/web/app/robots.ts @@ -0,0 +1,12 @@ +import type { MetadataRoute } from "next"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + disallow: ["/api/"], + }, + sitemap: "https://ground.codes/sitemap.xml", + }; +} diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts new file mode 100644 index 00000000..08af0e14 --- /dev/null +++ b/apps/web/app/sitemap.ts @@ -0,0 +1,12 @@ +import type { MetadataRoute } from "next"; + +export default function sitemap(): MetadataRoute.Sitemap { + return [ + { + url: "https://ground.codes", + lastModified: new Date(), + changeFrequency: "weekly", + priority: 1, + }, + ]; +} diff --git a/apps/web/components/google-map/context/use-map-context.ts b/apps/web/components/google-map/context/use-map-context.ts index 33b0904d..483c521f 100644 --- a/apps/web/components/google-map/context/use-map-context.ts +++ b/apps/web/components/google-map/context/use-map-context.ts @@ -2,7 +2,6 @@ import { useState, useCallback, useEffect } from "react"; import { useGridSystem } from "@/lib/grid-system"; import { useMapCoordinates } from "../hooks/use-map-coordinates"; import { useGeolocation } from "../hooks/use-geolocation"; -import { googleMapDarkTheme } from "@/lib/map/google-map-theme"; import { Coordinates, MapContextType, defaultCenter } from "./types"; export const useMapContextState = (): MapContextType => { diff --git a/apps/web/components/google-map/hooks/use-air-quality.ts b/apps/web/components/google-map/hooks/use-air-quality.ts index eb1da23e..5cb7d78d 100644 --- a/apps/web/components/google-map/hooks/use-air-quality.ts +++ b/apps/web/components/google-map/hooks/use-air-quality.ts @@ -42,6 +42,7 @@ export const useAirQuality = ( const [weatherData, setWeatherData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [isUnavailable, setIsUnavailable] = useState(false); // Debounce coordinates to avoid excessive API calls const debouncedCoordinates = useDebounce(coordinates, debounceMs); @@ -66,6 +67,7 @@ export const useAirQuality = ( setIsLoading(true); setError(null); + setIsUnavailable(false); try { // Fetch both air quality and weather data using the combined API endpoint @@ -86,6 +88,13 @@ export const useAirQuality = ( const data: CombinedWeatherData = await response.json(); + if (data.unavailable) { + setAirQuality(null); + setWeatherData(null); + setIsUnavailable(true); + return; + } + // Handle air quality data if (data.airQuality) { setAirQuality(data.airQuality); @@ -127,6 +136,7 @@ export const useAirQuality = ( weatherIcon, isLoading, error, + isUnavailable, weatherData, }; }; diff --git a/apps/web/components/google-map/hooks/use-device-orientation.ts b/apps/web/components/google-map/hooks/use-device-orientation.ts index 42f738ca..72effd6e 100644 --- a/apps/web/components/google-map/hooks/use-device-orientation.ts +++ b/apps/web/components/google-map/hooks/use-device-orientation.ts @@ -11,6 +11,17 @@ interface UseDeviceOrientationReturn extends DeviceOrientationState { requestPermission: () => Promise; } +type DeviceOrientationPermission = "granted" | "denied" | "prompt"; + +interface DeviceOrientationEventWithCompass extends DeviceOrientationEvent { + compassHeading?: number; + webkitCompassHeading?: number; +} + +interface DeviceOrientationEventConstructorWithPermission { + requestPermission?: () => Promise; +} + export const useDeviceOrientation = (): UseDeviceOrientationReturn => { const [orientationState, setOrientationState] = useState({ @@ -61,16 +72,16 @@ export const useDeviceOrientation = (): UseDeviceOrientationReturn => { // Handle device orientation event with debouncing and threshold const handleOrientation = useCallback( - (event: DeviceOrientationEvent) => { + (event: DeviceOrientationEventWithCompass) => { let newHeading: number | null = null; // For devices that support deviceorientationabsolute - if ("compassHeading" in event) { - newHeading = (event as any).compassHeading; + if (typeof event.compassHeading === "number") { + newHeading = event.compassHeading; } // For devices that support deviceorientation with webkitCompassHeading - else if ("webkitCompassHeading" in event) { - newHeading = (event as any).webkitCompassHeading; + else if (typeof event.webkitCompassHeading === "number") { + newHeading = event.webkitCompassHeading; } // For devices that only provide alpha value (relative to initial position) else if (event.alpha !== null) { @@ -147,13 +158,12 @@ export const useDeviceOrientation = (): UseDeviceOrientationReturn => { } // For iOS devices - if ( - typeof (DeviceOrientationEvent as any).requestPermission === "function" - ) { + const orientationEvent = + window.DeviceOrientationEvent as unknown as DeviceOrientationEventConstructorWithPermission; + + if (typeof orientationEvent.requestPermission === "function") { try { - const permission = await ( - DeviceOrientationEvent as any - ).requestPermission(); + const permission = await orientationEvent.requestPermission(); const granted = permission === "granted"; if (granted) { @@ -224,8 +234,10 @@ export const useDeviceOrientation = (): UseDeviceOrientationReturn => { useEffect(() => { const checkSupport = () => { const supported = window.DeviceOrientationEvent !== undefined; + const orientationEvent = + window.DeviceOrientationEvent as unknown as DeviceOrientationEventConstructorWithPermission; const permissionAPI = - typeof (DeviceOrientationEvent as any).requestPermission === "function"; + typeof orientationEvent.requestPermission === "function"; setOrientationState((prev) => ({ ...prev, diff --git a/apps/web/components/google-map/hooks/use-geolocation.ts b/apps/web/components/google-map/hooks/use-geolocation.ts index 6d5f9844..cc18d607 100644 --- a/apps/web/components/google-map/hooks/use-geolocation.ts +++ b/apps/web/components/google-map/hooks/use-geolocation.ts @@ -105,6 +105,26 @@ export const useGeolocation = ( } }, []); + // Update location function + const updateUserLocation = useCallback( + (position: GeolocationPosition) => { + const newLocation = { + lat: position.coords.latitude, + lng: position.coords.longitude, + accuracy: position.coords.accuracy, + heading: + position.coords.heading !== null && + position.coords.heading !== undefined + ? position.coords.heading + : userLocation?.heading || null, + }; + + setUserLocation(newLocation); + return newLocation; + }, + [userLocation], + ); + // Start watching position const startWatchingPosition = useCallback(() => { if (navigator.geolocation) { @@ -113,9 +133,6 @@ export const useGeolocation = ( // Update loading ref directly instead of state isLoadingRef.current = true; - // Maintain previous location info (include heading) - const prevLocation = userLocation; - const watchId = navigator.geolocation.watchPosition( (position) => { // Update loading ref directly @@ -135,44 +152,27 @@ export const useGeolocation = ( // Update location setUserLocation(newLocation); }, - (error) => { + () => { // Update loading ref directly isLoadingRef.current = false; // setError(error.message); }, { - enableHighAccuracy: true, - timeout: 10000, - maximumAge: 1000, + enableHighAccuracy, + timeout, + maximumAge, }, ); geolocationRequestIdRef.current = watchId; } - }, [cancelGeolocationRequest, userLocation]); - - // Update location function - const updateUserLocation = useCallback( - (position: GeolocationPosition) => { - // Create new location info - const newLocation = { - lat: position.coords.latitude, - lng: position.coords.longitude, - accuracy: position.coords.accuracy, - // Heading processing (update if new heading exists, otherwise maintain previous heading) - heading: - position.coords.heading !== null && - position.coords.heading !== undefined - ? position.coords.heading - : userLocation?.heading || null, - }; - - // Update location - setUserLocation(newLocation); - return newLocation; - }, - [userLocation], - ); + }, [ + cancelGeolocationRequest, + enableHighAccuracy, + maximumAge, + timeout, + updateUserLocation, + ]); const getUserLocation = useCallback(() => { if (navigator.geolocation) { @@ -222,19 +222,22 @@ export const useGeolocation = ( isLoadingRef.current = false; }, { - enableHighAccuracy: true, - timeout: 5000, - maximumAge: 0, + enableHighAccuracy, + timeout, + maximumAge, }, ); } }, [ cancelGeolocationRequest, + enableHighAccuracy, isTrackingMode, map, + maximumAge, onPositionUpdate, setCenter, setSelectedArea, + timeout, updateUserLocation, userLocationLoaded, ]); diff --git a/apps/web/components/google-map/hooks/use-location-tracking.ts b/apps/web/components/google-map/hooks/use-location-tracking.ts index 440c5fda..8df4fe7b 100644 --- a/apps/web/components/google-map/hooks/use-location-tracking.ts +++ b/apps/web/components/google-map/hooks/use-location-tracking.ts @@ -5,7 +5,6 @@ import { useDeviceOrientation } from "./use-device-orientation"; interface UseLocationTrackingProps { map: google.maps.Map | null; onLocationUpdate: (location: Coordinates) => void; - isTrackingMode?: boolean; } /** @@ -15,7 +14,6 @@ interface UseLocationTrackingProps { export const useLocationTracking = ({ map, onLocationUpdate, - isTrackingMode = false, }: UseLocationTrackingProps) => { // Default value is OFF const [locationMode, setLocationMode] = useState( @@ -34,11 +32,6 @@ export const useLocationTracking = ({ [] ); - // Loading state setter function - const setIsLoadingLocationRef = useCallback((loading: boolean) => { - isLoadingLocationRef.current = loading; - }, []); - // Watch position ID reference const watchPositionIdRef = useRef(null); @@ -221,7 +214,14 @@ export const useLocationTracking = ({ // Release loading state (direct ref update) isLoadingLocationRef.current = false; - }, [locationMode]); + }, []); + + /** + * Set location mode directly + */ + const setLocationModeDirectly = useCallback((mode: LocationMode) => { + setLocationMode(mode); + }, []); /** * Toggle location mode function @@ -242,17 +242,7 @@ export const useLocationTracking = ({ setLocationModeDirectly(LocationMode.OFF); break; } - }, [locationMode]); - - /** - * Set location mode directly - */ - const setLocationModeDirectly = useCallback( - (mode: LocationMode) => { - setLocationMode(mode); - }, - [locationMode] - ); + }, [locationMode, setLocationModeDirectly]); /** * Update tracking state when location mode changes diff --git a/apps/web/components/google-map/hooks/use-map-container.ts b/apps/web/components/google-map/hooks/use-map-container.ts index 4e473fce..c863f0a6 100644 --- a/apps/web/components/google-map/hooks/use-map-container.ts +++ b/apps/web/components/google-map/hooks/use-map-container.ts @@ -8,6 +8,12 @@ import { useLanguage } from "./use-language"; import { useI18n } from "@/lib/i18n/i18n-context"; import { useLocationTracking } from "./use-location-tracking"; import { Coordinates, LocationMode } from "../types"; +import { + GroundCodeSearchResult, + searchGroundCodes, +} from "@/lib/code/ground-codes"; +import { parseGroundCodeSharePath } from "@/lib/code/share-url"; +import { getGroundCodeLanguage } from "@/lib/i18n/ground-code-language"; import { CelestialBody, createPlanetaryMapType, @@ -90,6 +96,9 @@ const getInitialMapType = (body: CelestialBody): EarthMapType => { const getInitialBody = (): CelestialBody => { if (typeof window === "undefined") return "earth"; + const sharedCode = parseGroundCodeSharePath(window.location.pathname); + if (sharedCode) return sharedCode.body; + return parseCelestialBody( new URLSearchParams(window.location.search).get("body"), ); @@ -128,7 +137,7 @@ const getInitialZoom = (body: CelestialBody): number => { export const useMapContainer = () => { const { getUserLanguage } = useLanguage(); - const { isChangingLanguage } = useI18n(); + const { isChangingLanguage, locale } = useI18n(); const [body, setBody] = useState(getInitialBody); const isEarth = body === "earth"; const [planetaryLayerId, setPlanetaryLayerId] = useState(() => @@ -137,14 +146,18 @@ export const useMapContainer = () => { // Get language from cookie for Google Maps API const mapLanguage = isChangingLanguage ? "en" : getUserLanguage(); + const googleMapsApiKey = + process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY?.trim() ?? ""; + const hasGoogleMapsApiKey = googleMapsApiKey.length > 0; - // Load Google Maps API - const { isLoaded } = useJsApiLoader({ + // Load Google Maps API. The hook must be called unconditionally. + const { isLoaded: isGoogleMapsLoaded } = useJsApiLoader({ id: "google-map-script", - googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY!, - language: mapLanguage, // Language code for Google Maps API - libraries, // Use the constant libraries array + googleMapsApiKey: googleMapsApiKey || "missing-google-maps-api-key", + language: mapLanguage, + libraries, }); + const isLoaded = hasGoogleMapsApiKey ? isGoogleMapsLoaded : true; // Map state const [map, setMap] = useState(null); @@ -182,6 +195,15 @@ export const useMapContainer = () => { // Search result state const [searchedPlace, setSearchedPlace] = useState(null); + const [isGroundSearchLoading, setIsGroundSearchLoading] = useState(false); + const [groundSearchError, setGroundSearchError] = useState( + null, + ); + const [groundSearchResults, setGroundSearchResults] = useState< + GroundCodeSearchResult[] + >([]); + const [initialGroundSearchQuery, setInitialGroundSearchQuery] = useState(""); + const restoredSharePathRef = useRef(false); const [searchMarker, setSearchMarker] = useState( null, ); @@ -197,13 +219,10 @@ export const useMapContainer = () => { locationMode, setLocationMode, isLoadingLocation, - isTrackingLocation, startLocationTracking, stopLocationTracking, - toggleLocationMode, } = useLocationTracking({ map, - isTrackingMode: true, onLocationUpdate: useCallback((location) => { if (location) { // Explicitly include heading information in state update @@ -225,15 +244,12 @@ export const useMapContainer = () => { if (isLoadingLocation !== undefined) { setIsLoading(isLoadingLocation); } - // Empty dependency array to run only once on mount - }, []); + }, [isLoadingLocation]); // Get user location using the hook const { - userLocation: geoLocation, getUserLocation: getGeoLocation, cancelGeolocationRequest, - requestOrientationPermission, } = useGeolocation(map, setCenter, setSelectedArea, { autoGetLocation: isEarth, initialFetch: isEarth, @@ -295,6 +311,7 @@ export const useMapContainer = () => { userLocation, getGeoLocation, isEarth, + setLocationMode, ]); const selectMapType = useCallback( @@ -458,7 +475,7 @@ export const useMapContainer = () => { removeMapEventHandlers, } = useGridSystem(showGrid, selectedArea, setSelectedArea, { locationMode, - setLocationMode, + setLocationMode: (mode) => setLocationMode(mode as LocationMode), placeDetailsVisible, setPlaceDetailsVisible, setSelectedPlaceId, @@ -559,6 +576,7 @@ export const useMapContainer = () => { useEffect(() => { if (typeof window === "undefined") return; + if (parseGroundCodeSharePath(window.location.pathname)) return; const params = new URLSearchParams(window.location.search); if (body === "earth") { @@ -658,10 +676,10 @@ export const useMapContainer = () => { // Initialize InfoWindow useEffect(() => { - if (isLoaded && !infoWindow) { + if (hasGoogleMapsApiKey && isGoogleMapsLoaded && !infoWindow) { setInfoWindow(new google.maps.InfoWindow()); } - }, [isLoaded, infoWindow]); + }, [hasGoogleMapsApiKey, isGoogleMapsLoaded, infoWindow]); // Handle place selection from search const handlePlaceSelect = useCallback( @@ -719,6 +737,82 @@ export const useMapContainer = () => { [map, searchMarker, infoWindow, locationMode, setLocationMode], ); + const applyGroundSearchResult = useCallback( + (result: GroundCodeSearchResult) => { + const resultBody = parseCelestialBody(String(result.body)); + const nextLocation = { lat: result.lat, lng: result.lng }; + const nextZoom = resultBody === "earth" ? 14 : 5; + + if (resultBody !== body) { + selectBody(resultBody); + } + + if (locationMode === LocationMode.TRACKING) { + setLocationMode(LocationMode.OFF); + } + + setPlaceDetailsVisible(false); + setSelectedPlaceId(null); + setSelectedLocation(null); + setCenter(nextLocation); + setZoom(nextZoom); + userZoomRef.current = nextZoom; + setSelectedArea(nextLocation); + setShowInfoWindow(true); + + if (map && resultBody === body) { + map.setCenter(nextLocation); + map.setZoom(nextZoom); + } + }, + [body, locationMode, map, selectBody, setLocationMode], + ); + + const handleGroundSearch = useCallback( + async (query: string, options?: { body?: CelestialBody }) => { + const trimmedQuery = query.trim(); + if (!trimmedQuery) return; + const searchBody = options?.body ?? body; + + try { + setIsGroundSearchLoading(true); + setGroundSearchError(null); + const response = await searchGroundCodes({ + query: trimmedQuery, + language: getGroundCodeLanguage(locale), + body: searchBody, + maxResults: 5, + }); + setGroundSearchResults(response.results); + const result = response.results[0]; + + if (!result) { + setGroundSearchError("map.search.noResults"); + return; + } + + applyGroundSearchResult(result); + } catch (error) { + console.error("Ground code search failed:", error); + setGroundSearchError("map.search.error"); + } finally { + setIsGroundSearchLoading(false); + } + }, + [applyGroundSearchResult, body, locale], + ); + + useEffect(() => { + if (restoredSharePathRef.current || typeof window === "undefined") return; + + const sharedCode = parseGroundCodeSharePath(window.location.pathname); + if (!sharedCode) return; + + restoredSharePathRef.current = true; + setInitialGroundSearchQuery(sharedCode.code); + void handleGroundSearch(sharedCode.code, { body: sharedCode.body }); + }, [handleGroundSearch]); + // Map load handler const onLoad = useCallback( (mapInstance: google.maps.Map) => { @@ -762,7 +856,7 @@ export const useMapContainer = () => { // Intercept POI click event to prevent default InfoWindow display mapInstance.addListener("click", (e: google.maps.MapMouseEvent) => { - if ((e as any).placeId) { + if ((e as google.maps.IconMouseEvent).placeId) { // Stop default POI click action (e as google.maps.IconMouseEvent).stop(); } @@ -837,6 +931,7 @@ export const useMapContainer = () => { return { // Map state isLoaded, + hasGoogleMapsApiKey, map, center, zoom, @@ -883,6 +978,12 @@ export const useMapContainer = () => { // Search state searchedPlace, handlePlaceSelect, + handleGroundSearch, + handleGroundSearchResultSelect: applyGroundSearchResult, + isGroundSearchLoading, + groundSearchError, + groundSearchResults, + initialGroundSearchQuery, // Place Details state placeDetailsVisible, diff --git a/apps/web/components/google-map/hooks/use-map-coordinates.ts b/apps/web/components/google-map/hooks/use-map-coordinates.ts index 6f9455c6..6908dcaf 100644 --- a/apps/web/components/google-map/hooks/use-map-coordinates.ts +++ b/apps/web/components/google-map/hooks/use-map-coordinates.ts @@ -40,7 +40,7 @@ export const useMapCoordinates = ( } finally { setIsEncoding(false); } - }, [selectedArea, body]); // Locale dependency removed + }, [selectedArea, body, locale]); return { encodedCoordinates, diff --git a/apps/web/components/google-map/index.tsx b/apps/web/components/google-map/index.tsx index 7fff0447..9ec08dac 100644 --- a/apps/web/components/google-map/index.tsx +++ b/apps/web/components/google-map/index.tsx @@ -1,4 +1,5 @@ -import React from "react"; +import React, { useState } from "react"; +import { FaCopy, FaShareAlt } from "react-icons/fa"; import { GoogleMap } from "@react-google-maps/api"; import { useMapContainer } from "./hooks/use-map-container"; import MapMarkers from "./map-markers"; @@ -9,11 +10,14 @@ import WeatherInfo from "./weather-info"; import { useI18n } from "@/lib/i18n/i18n-context"; import Earth3DMap from "./earth-3d-map"; import Planetary3DMap from "./planetary-3d-map"; +import { buildGroundCodeSharePath } from "@/lib/code/share-url"; function GoogleMapComponent() { const { t, isChangingLanguage } = useI18n(); + const [feedbackMessage, setFeedbackMessage] = useState(null); const { isLoaded, + hasGoogleMapsApiKey, center, zoom, map, @@ -26,10 +30,15 @@ function GoogleMapComponent() { toggleGrid, getUserLocation, isLoading, - isTrackingLocation, encodedCoordinates, isEncoding, handlePlaceSelect, + handleGroundSearch, + handleGroundSearchResultSelect, + isGroundSearchLoading, + groundSearchError, + groundSearchResults, + initialGroundSearchQuery, mapType, selectMapType, body, @@ -52,14 +61,132 @@ function GoogleMapComponent() { } = useMapContainer(); const isEarth3D = isEarth && mapType === "earth3d"; const isPlanetary3D = !isEarth && mapType === "planetary3d"; - const showGoogleMap = !isEarth3D && !isPlanetary3D; + const showGoogleMap = hasGoogleMapsApiKey && !isEarth3D && !isPlanetary3D; + const search = ( + + ); + const copyGroundCode = async () => { + if (!encodedCoordinates) return; + await navigator.clipboard?.writeText(encodedCoordinates); + setFeedbackMessage(t("map.coordinates.copied")); + window.setTimeout(() => setFeedbackMessage(null), 1800); + }; + const shareSelectedArea = async () => { + if (!selectedArea) return; + + const url = `${window.location.origin}${buildGroundCodeSharePath({ + code: encodedCoordinates, + body, + })}`; + + if (navigator.share) { + await navigator.share({ + title: encodedCoordinates, + text: encodedCoordinates, + url, + }); + return; + } + + await navigator.clipboard?.writeText(url); + setFeedbackMessage(t("map.coordinates.shareCopied")); + window.setTimeout(() => setFeedbackMessage(null), 1800); + }; + const selectedAreaPanel = selectedArea ? ( +
+
{t("map.coordinates.title")}
+
+ {selectedArea.lat.toFixed(6)}, {selectedArea.lng.toFixed(6)} +
+
+ {isEncoding ? t("map.encoding") : encodedCoordinates} +
+ {!isEncoding && encodedCoordinates && ( +
+ + +
+ )} + {feedbackMessage && ( +
{feedbackMessage}
+ )} +
+ ) : null; // Language change in progress, do not render map component if (isChangingLanguage) { return
; } - return isLoaded ? ( + if (!isLoaded) { + return ( +
+ {search} +
{t("map.loading")}
+ {selectedAreaPanel} +
+ ); + } + + if (!hasGoogleMapsApiKey) { + return ( +
+
+
+
+ {search} + + {selectedAreaPanel} +
+ ); + } + + return (
{showGoogleMap ? ( )} - {isEarth && !isEarth3D && ( - - )} + {search} @@ -163,8 +288,6 @@ function GoogleMapComponent() { /> )}
- ) : ( -
{t("map.loading")}
); } diff --git a/apps/web/components/google-map/map-controls.tsx b/apps/web/components/google-map/map-controls.tsx index f4c54144..0d084f9c 100644 --- a/apps/web/components/google-map/map-controls.tsx +++ b/apps/web/components/google-map/map-controls.tsx @@ -23,13 +23,11 @@ interface MapControlsProps { resetMapHeading: () => void; setMapHeading?: (heading: number) => void; isLoadingLocation?: boolean; - isTrackingLocation?: boolean; locationMode?: LocationMode; - isFullscreen?: boolean; - toggleFullscreen?: () => void; body?: CelestialBody; selectBody?: (body: CelestialBody) => void; isEarth?: boolean; + hasSelectedArea?: boolean; } const MapControls: React.FC = ({ @@ -42,13 +40,11 @@ const MapControls: React.FC = ({ resetMapHeading, setMapHeading, isLoadingLocation = false, - isTrackingLocation = false, locationMode = 0, - isFullscreen = false, - toggleFullscreen = () => {}, body = "earth", selectBody = () => {}, isEarth = true, + hasSelectedArea = false, }) => { const { t, locale, setLocale } = useI18n(); const [showBodyOptions, setShowBodyOptions] = useState(false); @@ -213,7 +209,14 @@ const MapControls: React.FC = ({ return ( <> {/* Map Type Control and Language Selector (Top Right) */} -
+
@@ -373,7 +376,14 @@ const MapControls: React.FC = ({
{/* Grid, Location, and Compass Controls (Bottom Right) */} -
+
void; + onPlaceSelect?: (place: google.maps.places.PlaceResult) => void; + onGroundSearch: (query: string) => Promise | void; + onGroundSearchResultSelect: (result: GroundCodeSearchResult) => void; + isGroundSearchLoading: boolean; + groundSearchError: string | null; + groundSearchResults: GroundCodeSearchResult[]; + initialQuery?: string | null; } -const MapSearch: React.FC = ({ map, onPlaceSelect }) => { +const MapSearch: React.FC = ({ + map, + onPlaceSelect, + onGroundSearch, + onGroundSearchResultSelect, + isGroundSearchLoading, + groundSearchError, + groundSearchResults, + initialQuery = null, +}) => { const { t } = useI18n(); + const [query, setQuery] = useState(""); const searchInputRef = useRef(null); const autocompleteRef = useRef(null); const infoWindowRef = useRef(null); useEffect(() => { - if (!map || !searchInputRef.current) return; + if (initialQuery && !query) { + setQuery(initialQuery); + } + }, [initialQuery, query]); + + useEffect(() => { + if (!map || !searchInputRef.current || !onPlaceSelect) return; + if (typeof google === "undefined" || !google.maps?.places) return; // Initialize the InfoWindow infoWindowRef.current = new google.maps.InfoWindow(); @@ -41,6 +65,7 @@ const MapSearch: React.FC = ({ map, onPlaceSelect }) => { return; } + setQuery(place.name ?? searchInputRef.current?.value ?? ""); onPlaceSelect(place); } ); @@ -53,16 +78,35 @@ const MapSearch: React.FC = ({ map, onPlaceSelect }) => { }; }, [map, onPlaceSelect]); + const submitSearch = async (event: React.FormEvent) => { + event.preventDefault(); + const trimmedQuery = (searchInputRef.current?.value ?? query).trim(); + if (!trimmedQuery || isGroundSearchLoading) return; + setQuery(trimmedQuery); + await onGroundSearch(trimmedQuery); + }; + + const translatedGroundSearchError = groundSearchError?.startsWith("map.") + ? t(groundSearchError) + : groundSearchError; + return ( -
-
+
+
setQuery(event.target.value)} + onInput={(event) => setQuery(event.currentTarget.value)} placeholder={t("map.search.placeholder")} - className="w-full p-2 pl-10 bg-transparent text-white placeholder-text-white border-none focus:outline-none" + className="h-11 w-full bg-transparent py-2 pl-10 pr-12 text-sm text-white placeholder:text-white/70 focus:outline-none" + aria-label={t("map.search.placeholder")} /> -
+
= ({ map, onPlaceSelect }) => { />
+
-
+ {translatedGroundSearchError && ( +
+ {translatedGroundSearchError} +
+ )} + {groundSearchResults.length > 1 && ( +
+ {groundSearchResults.map((result) => ( + + ))} +
+ )} +
); }; diff --git a/apps/web/components/google-map/place-details/helpers.ts b/apps/web/components/google-map/place-details/helpers.ts index cca2f86f..30646c59 100644 --- a/apps/web/components/google-map/place-details/helpers.ts +++ b/apps/web/components/google-map/place-details/helpers.ts @@ -33,7 +33,9 @@ export const getDayIndexFromString = (dayString: string | undefined): number => * Returns true when the place is open at the current moment. * Works with both `periods` and `weekday_text` data from the Places API. */ -export const isOpenNow = (placeDetails: any): boolean => { +export const isOpenNow = ( + placeDetails: google.maps.places.PlaceResult | null +): boolean => { try { if (placeDetails?.opening_hours?.periods) { const now = new Date(); @@ -41,16 +43,17 @@ export const isOpenNow = (placeDetails: any): boolean => { const currentTime = now.getHours() * 60 + now.getMinutes(); // 24-hour operation: single period with no close time + const firstPeriod = placeDetails.opening_hours.periods[0]; if ( placeDetails.opening_hours.periods.length === 1 && - placeDetails.opening_hours.periods[0].open && - !placeDetails.opening_hours.periods[0].close + firstPeriod?.open && + !firstPeriod.close ) { return true; } const todayPeriods = placeDetails.opening_hours.periods.filter( - (period: any) => period.open && period.open.day === day + (period) => period.open && period.open.day === day ); for (const period of todayPeriods) { diff --git a/apps/web/components/google-map/place-details/index.tsx b/apps/web/components/google-map/place-details/index.tsx index 48b5c243..63457cd8 100644 --- a/apps/web/components/google-map/place-details/index.tsx +++ b/apps/web/components/google-map/place-details/index.tsx @@ -94,7 +94,7 @@ const PlaceDetails: React.FC = ({ @@ -118,7 +118,7 @@ const PlaceDetails: React.FC = ({
{Array.from({ length: 5 }).map((_, i) => ( - {i < Math.floor(placeDetails.rating) ? ( + {i < Math.floor(placeDetails.rating ?? 0) ? ( ) : ( diff --git a/apps/web/components/google-map/place-details/photo-gallery.tsx b/apps/web/components/google-map/place-details/photo-gallery.tsx index ca21a87e..b7d69d90 100644 --- a/apps/web/components/google-map/place-details/photo-gallery.tsx +++ b/apps/web/components/google-map/place-details/photo-gallery.tsx @@ -1,9 +1,10 @@ import React from "react"; +import Image from "next/image"; import { FaImages, FaExpand } from "react-icons/fa"; import { useI18n } from "@/lib/i18n/i18n-context"; interface PhotoGalleryProps { - photos: any[]; + photos: google.maps.places.PlacePhoto[]; photoErrors: Record; placeName: string; onPhotoError: (index: number) => void; @@ -32,7 +33,7 @@ const PhotoGallery: React.FC = ({
- {photos.slice(0, 4).map((photo: any, index: number) => { + {photos.slice(0, 4).map((photo, index) => { if (photoErrors[index]) return null; const photoUrl = photo.getUrl({ maxWidth: 300, maxHeight: 300 }); @@ -45,9 +46,12 @@ const PhotoGallery: React.FC = ({ onClick={() => onSelectPhoto(fullPhotoUrl)} title={t("common.viewFullImage")} > - {`${placeName} onPhotoError(index)} /> diff --git a/apps/web/components/google-map/place-details/photo-modal.tsx b/apps/web/components/google-map/place-details/photo-modal.tsx index 669dd41a..dfb47445 100644 --- a/apps/web/components/google-map/place-details/photo-modal.tsx +++ b/apps/web/components/google-map/place-details/photo-modal.tsx @@ -1,5 +1,6 @@ import React from "react"; import { createPortal } from "react-dom"; +import Image from "next/image"; import { FaTimes } from "react-icons/fa"; import { useI18n } from "@/lib/i18n/i18n-context"; @@ -23,9 +24,12 @@ const PhotoModal: React.FC = ({ photoUrl, onClose }) => {
- {t("common.viewFullImage")} e.stopPropagation()} style={{ maxWidth: "95vw" }} diff --git a/apps/web/components/google-map/place-details/use-place-details.ts b/apps/web/components/google-map/place-details/use-place-details.ts index e5543bf6..3a970093 100644 --- a/apps/web/components/google-map/place-details/use-place-details.ts +++ b/apps/web/components/google-map/place-details/use-place-details.ts @@ -14,7 +14,8 @@ export const usePlaceDetails = ( const { t } = useI18n(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [placeDetails, setPlaceDetails] = useState(null); + const [placeDetails, setPlaceDetails] = + useState(null); // Reset everything when the panel is hidden useEffect(() => { @@ -73,7 +74,7 @@ export const usePlaceDetails = ( }; fetchPlaceDetails(); - }, [map, placeId, visible]); + }, [map, onResetPhotoState, placeId, t, visible]); return { isLoading, error, placeDetails }; }; diff --git a/apps/web/components/google-map/weather-detail-modal.tsx b/apps/web/components/google-map/weather-detail-modal.tsx index e1343b6f..627e6d56 100644 --- a/apps/web/components/google-map/weather-detail-modal.tsx +++ b/apps/web/components/google-map/weather-detail-modal.tsx @@ -1,4 +1,5 @@ import React, { useRef, useEffect } from "react"; +import Image from "next/image"; import { useI18n } from "@/lib/i18n/i18n-context"; import { AirQualityData, WeatherData } from "@/app/api/weather-data/route"; @@ -146,20 +147,20 @@ const WeatherDetailModal: React.FC = ({ }; // Convert RGB object to CSS color string - const rgbToColorString = (colorObj: any) => { - if (!colorObj || typeof colorObj !== "object") return "#808080"; // Default gray - - // Check if it's already a hex color string + const rgbToColorString = (colorObj: unknown) => { if (typeof colorObj === "string" && colorObj.startsWith("#")) { return colorObj; } + if (!colorObj || typeof colorObj !== "object") return "#808080"; // Default gray + // Check if it has RGB components if ("red" in colorObj && "green" in colorObj && "blue" in colorObj) { + const rgb = colorObj as { red: number; green: number; blue: number }; // Convert 0-1 range to 0-255 range - const r = Math.round(colorObj.red * 255); - const g = Math.round(colorObj.green * 255); - const b = Math.round(colorObj.blue * 255); + const r = Math.round(rgb.red * 255); + const g = Math.round(rgb.green * 255); + const b = Math.round(rgb.blue * 255); return `rgb(${r}, ${g}, ${b})`; } @@ -206,9 +207,12 @@ const WeatherDetailModal: React.FC = ({ {weatherData.name || t("weather.currentLocation")} {weatherData.weather && weatherData.weather[0] && ( - {weatherData.weather[0].description} )} diff --git a/apps/web/components/google-map/weather-info.tsx b/apps/web/components/google-map/weather-info.tsx index 1789f547..30b648bd 100644 --- a/apps/web/components/google-map/weather-info.tsx +++ b/apps/web/components/google-map/weather-info.tsx @@ -50,30 +50,35 @@ const WeatherInfo: React.FC = ({ airQuality, isLoading, error, + isUnavailable, weatherData, } = useAirQuality(mapCenter, { debounceMs: 1500, // Debounce map center changes to prevent excessive API calls }); + if (isUnavailable || (!isLoading && !temperature && !airQuality && !error)) { + return null; + } + // Get the air quality index from the response const airQualityIndex = airQuality?.indexes?.[0]?.aqi || 0; const airQualityCategory = airQuality?.indexes?.[0]?.category || ""; // Convert RGB object to CSS color string - const rgbToColorString = (colorObj: any) => { - if (!colorObj || typeof colorObj !== "object") return "#808080"; // Default gray - - // Check if it's already a hex color string + const rgbToColorString = (colorObj: unknown) => { if (typeof colorObj === "string" && colorObj.startsWith("#")) { return colorObj; } + if (!colorObj || typeof colorObj !== "object") return "#808080"; // Default gray + // Check if it has RGB components if ("red" in colorObj && "green" in colorObj && "blue" in colorObj) { + const rgb = colorObj as { red: number; green: number; blue: number }; // Convert 0-1 range to 0-255 range - const r = Math.round(colorObj.red * 255); - const g = Math.round(colorObj.green * 255); - const b = Math.round(colorObj.blue * 255); + const r = Math.round(rgb.red * 255); + const g = Math.round(rgb.green * 255); + const b = Math.round(rgb.blue * 255); return `rgb(${r}, ${g}, ${b})`; } diff --git a/apps/web/e2e/ground-code-share.spec.ts b/apps/web/e2e/ground-code-share.spec.ts new file mode 100644 index 00000000..08637c0b --- /dev/null +++ b/apps/web/e2e/ground-code-share.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from "@playwright/test"; + +const boxesOverlap = ( + a: { x: number; y: number; width: number; height: number }, + b: { x: number; y: number; width: number; height: number }, +) => + a.x < b.x + b.width && + a.x + a.width > b.x && + a.y < b.y + b.height && + a.y + a.height > b.y; + +test("opens a canonical Earth Ground Code share URL", async ({ page }) => { + test.setTimeout(90_000); + const searchResponse = page.waitForResponse((response) => + response.url().includes("/v1/search"), + ); + const encodeResponse = page.waitForResponse((response) => + response.url().includes("/v1/encode"), + ); + await page.goto("/%EC%84%9C%EC%9A%B8-%EC%95%88%EB%B0%A9"); + await expect((await searchResponse).ok()).toBe(true); + await expect((await encodeResponse).ok()).toBe(true); + + await expect(page.getByText(/37\.566000/).first()).toBeVisible(); + await expect(page.getByRole("button", { name: /Copy|복사/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /Share|공유/ })).toBeVisible(); +}); + +test("searches partial region names and shows selectable results", async ({ + page, +}) => { + test.setTimeout(90_000); + await page.goto("/?map=roadmap"); + + const search = page.getByRole("textbox", { + name: /그라운드 코드|Ground Code/, + }); + await search.fill("Seo"); + const searchResponse = page.waitForResponse((response) => + response.url().includes("/v1/search"), + ); + await search.press("Enter"); + await expect((await searchResponse).ok()).toBe(true); + + await expect(page.getByText("Seoul").first()).toBeVisible(); + await expect(page.getByText("Seongnamsi").first()).toBeVisible(); +}); + +test("keeps mobile map controls clear of search and selected area panel", async ({ + page, +}) => { + test.setTimeout(90_000); + await page.setViewportSize({ width: 390, height: 844 }); + + const searchResponse = page.waitForResponse((response) => + response.url().includes("/v1/search"), + ); + const encodeResponse = page.waitForResponse((response) => + response.url().includes("/v1/encode"), + ); + await page.goto("/%EC%84%9C%EC%9A%B8-%EC%95%88%EB%B0%A9"); + await expect((await searchResponse).ok()).toBe(true); + await expect((await encodeResponse).ok()).toBe(true); + await expect(page.getByText(/37\.566000/).first()).toBeVisible(); + + const searchBox = await page + .getByRole("textbox", { name: /그라운드 코드|Ground Code/ }) + .boundingBox(); + const settingsBox = await page + .getByTestId("map-settings-controls") + .boundingBox(); + const actionBox = await page.getByTestId("map-action-controls").boundingBox(); + const panelBox = await page.getByTestId("selected-area-panel").boundingBox(); + + expect(searchBox).not.toBeNull(); + expect(settingsBox).not.toBeNull(); + expect(actionBox).not.toBeNull(); + expect(panelBox).not.toBeNull(); + + expect(boxesOverlap(settingsBox!, searchBox!)).toBe(false); + expect(boxesOverlap(settingsBox!, panelBox!)).toBe(false); + expect(boxesOverlap(actionBox!, panelBox!)).toBe(false); +}); + +test("shows a visible error for an invalid code-shaped share URL", async ({ + page, +}) => { + test.setTimeout(90_000); + const searchResponse = page.waitForResponse((response) => + response.url().includes("/v1/search"), + ); + + await page.goto("/Seoul-notrealcode"); + await expect((await searchResponse).ok()).toBe(true); + await expect( + page.getByText(/No matching Ground Code|일치하는 그라운드 코드/), + ).toBeVisible(); + await expect( + page.getByRole("textbox", { name: /그라운드 코드|Ground Code/ }), + ).toHaveValue("Seoul-notrealcode"); +}); diff --git a/apps/web/e2e/visual-qa.spec.ts b/apps/web/e2e/visual-qa.spec.ts new file mode 100644 index 00000000..5fb4cd63 --- /dev/null +++ b/apps/web/e2e/visual-qa.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; + +const waitForShareUrlToResolve = async (page: import("@playwright/test").Page) => { + const searchResponse = page.waitForResponse((response) => + response.url().includes("/v1/search"), + ); + const encodeResponse = page.waitForResponse((response) => + response.url().includes("/v1/encode"), + ); + + await page.goto("/%EC%84%9C%EC%9A%B8-%EC%95%88%EB%B0%A9"); + await expect((await searchResponse).ok()).toBe(true); + await expect((await encodeResponse).ok()).toBe(true); + await expect(page.getByTestId("selected-area-panel")).toBeVisible(); +}; + +test.describe("visual QA capture", () => { + test("captures desktop, mobile, and API docs screenshots", async ({ + page, + }, testInfo) => { + test.setTimeout(120_000); + + await page.setViewportSize({ width: 1440, height: 1000 }); + await waitForShareUrlToResolve(page); + await page.screenshot({ + path: testInfo.outputPath("desktop-map.png"), + fullPage: true, + }); + + await page.setViewportSize({ width: 390, height: 844 }); + await waitForShareUrlToResolve(page); + await page.screenshot({ + path: testInfo.outputPath("mobile-map.png"), + fullPage: true, + }); + + await page.goto("http://127.0.0.1:3000/docs"); + await expect(page.getByRole("heading", { name: "Ground Codes API" })) + .toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath("api-docs.png"), + fullPage: true, + }); + }); +}); diff --git a/apps/web/lib/code/ground-codes.test.ts b/apps/web/lib/code/ground-codes.test.ts new file mode 100644 index 00000000..96941225 --- /dev/null +++ b/apps/web/lib/code/ground-codes.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { encode, searchGroundCodes } from "./ground-codes"; + +const originalFetch = globalThis.fetch; +const originalApiUrl = process.env.NEXT_PUBLIC_GROUND_CODES_API_URL; + +afterEach(() => { + globalThis.fetch = originalFetch; + if (originalApiUrl === undefined) { + delete process.env.NEXT_PUBLIC_GROUND_CODES_API_URL; + } else { + process.env.NEXT_PUBLIC_GROUND_CODES_API_URL = originalApiUrl; + } +}); + +describe("ground-codes API client", () => { + test("encodes through the versioned API and configurable base URL", async () => { + process.env.NEXT_PUBLIC_GROUND_CODES_API_URL = "https://api.example.test/"; + const requests: Array<{ url: string; body: unknown }> = []; + globalThis.fetch = (async (url, init) => { + requests.push({ + url: String(url), + body: JSON.parse(String(init?.body)), + }); + return new Response("Seoul-Example-Code", { status: 200 }); + }) as typeof fetch; + + const code = await encode({ + lat: 37.566, + lng: 126.978, + language: "english", + body: "earth", + }); + + expect(code).toBe("Seoul-Example-Code"); + expect(requests[0]).toEqual({ + url: "https://api.example.test/v1/encode", + body: { + lat: 37.566, + lng: 126.978, + regionLevel: 2, + language: "english", + precisionMeters: 3, + body: "earth", + }, + }); + }); + + test("searches ground codes and region names through the versioned API", async () => { + const requests: Array<{ url: string; body: unknown }> = []; + globalThis.fetch = (async (url, init) => { + requests.push({ + url: String(url), + body: JSON.parse(String(init?.body)), + }); + return Response.json({ + query: "Seoul-Happy-Tiger", + results: [ + { + type: "ground-code", + label: "Seoul-Happy-Tiger", + lat: 37.566, + lng: 126.978, + body: "earth", + regionLevel: 2, + }, + ], + }); + }) as typeof fetch; + + const result = await searchGroundCodes({ + query: "Seoul-Happy-Tiger", + language: "english", + body: "earth", + maxResults: 5, + }); + + expect(result.results[0]?.type).toBe("ground-code"); + expect(requests[0]).toEqual({ + url: "https://api.ground.codes/v1/search", + body: { + query: "Seoul-Happy-Tiger", + regionLevel: 2, + language: "english", + body: "earth", + maxResults: 5, + }, + }); + }); +}); diff --git a/apps/web/lib/code/ground-codes.ts b/apps/web/lib/code/ground-codes.ts index ab9d6d89..fcd5662b 100644 --- a/apps/web/lib/code/ground-codes.ts +++ b/apps/web/lib/code/ground-codes.ts @@ -1,3 +1,37 @@ +type CelestialBody = "earth" | "moon" | "mars"; + +export interface GroundCodeSearchResult { + type: "ground-code" | "region" | "coordinates" | string; + label: string; + lat: number; + lng: number; + code?: string; + body: CelestialBody | string; + regionLevel: number; +} + +export interface GroundCodeSearchResponse { + query: string; + results: GroundCodeSearchResult[]; +} + +const getApiBaseUrl = () => + (process.env.NEXT_PUBLIC_GROUND_CODES_API_URL ?? "https://api.ground.codes") + .replace(/\/+$/, ""); + +const postApi = async (path: string, body: unknown) => { + const response = await fetch(`${getApiBaseUrl()}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) throw new Error("Ground Codes API request failed"); + return response; +}; + /** * Encodes a set of coordinates into a ground code. */ @@ -12,23 +46,43 @@ export const encode = async ({ lng: number; language?: string; precisionMeters?: number; - body?: "earth" | "moon" | "mars"; + body?: CelestialBody; }) => { - const response = await fetch("https://api.ground.codes/encode", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const response = await postApi( + "/v1/encode", + { lat, lng, regionLevel: 2, language, precisionMeters, body, - }), - }); + }, + ); - if (!response.ok) throw new Error("Failed to encode coordinates"); return await response.text(); }; + +export const searchGroundCodes = async ({ + query, + language = "english", + body = "earth", + regionLevel = 2, + maxResults = 5, +}: { + query: string; + language?: string; + body?: CelestialBody; + regionLevel?: number; + maxResults?: number; +}): Promise => { + const response = await postApi("/v1/search", { + query, + regionLevel, + language, + body, + maxResults, + }); + + return await response.json(); +}; diff --git a/apps/web/lib/code/share-url.test.ts b/apps/web/lib/code/share-url.test.ts new file mode 100644 index 00000000..47c789d9 --- /dev/null +++ b/apps/web/lib/code/share-url.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { + buildGroundCodeSharePath, + parseGroundCodeSharePath, +} from "./share-url"; + +describe("Ground Code share URLs", () => { + test("uses a root path for Earth codes", () => { + expect( + buildGroundCodeSharePath({ + code: "서울-안방", + body: "earth", + }), + ).toBe("/%EC%84%9C%EC%9A%B8-%EC%95%88%EB%B0%A9"); + }); + + test("uses explicit body prefixes for Moon and Mars codes", () => { + expect( + buildGroundCodeSharePath({ + code: "Olympus Mons-Happy-Tiger", + body: "mars", + }), + ).toBe("/mars/Olympus%20Mons-Happy-Tiger"); + + expect( + buildGroundCodeSharePath({ + code: "Mare Tranquillitatis-Happy-Tiger", + body: "moon", + }), + ).toBe("/moon/Mare%20Tranquillitatis-Happy-Tiger"); + }); + + test("parses Earth, Moon, and Mars share paths", () => { + expect(parseGroundCodeSharePath("/서울-안방")).toEqual({ + body: "earth", + code: "서울-안방", + }); + expect(parseGroundCodeSharePath("/mars/Olympus%20Mons-Happy-Tiger")).toEqual({ + body: "mars", + code: "Olympus Mons-Happy-Tiger", + }); + expect( + parseGroundCodeSharePath("/moon/Mare%20Tranquillitatis-Happy-Tiger/"), + ).toEqual({ + body: "moon", + code: "Mare Tranquillitatis-Happy-Tiger", + }); + }); + + test("ignores app routes that are not share codes", () => { + expect(parseGroundCodeSharePath("/api/weather-data")).toBeNull(); + expect(parseGroundCodeSharePath("/docs")).toBeNull(); + expect(parseGroundCodeSharePath("/about")).toBeNull(); + expect(parseGroundCodeSharePath("/Seoul-not-a-real-code")).toBeNull(); + expect(parseGroundCodeSharePath("/moon")).toBeNull(); + expect(parseGroundCodeSharePath("/moon/about")).toBeNull(); + }); +}); diff --git a/apps/web/lib/code/share-url.ts b/apps/web/lib/code/share-url.ts new file mode 100644 index 00000000..1143ff19 --- /dev/null +++ b/apps/web/lib/code/share-url.ts @@ -0,0 +1,64 @@ +type CelestialBody = "earth" | "moon" | "mars"; + +const bodyPrefixes = new Set(["moon", "mars"]); +const reservedRootSegments = new Set([ + "api", + "_next", + "docs", + "favicon.ico", + "robots.txt", + "sitemap.xml", +]); + +export interface GroundCodeSharePath { + body: CelestialBody; + code: string; +} + +export const buildGroundCodeSharePath = ({ + code, + body, +}: GroundCodeSharePath) => { + const encodedCode = encodeURIComponent(code); + return body === "earth" ? `/${encodedCode}` : `/${body}/${encodedCode}`; +}; + +const isCodeLikeShareSegment = (code: string) => { + const wordCount = code.split("-").filter(Boolean).length; + return wordCount >= 2 && wordCount <= 3; +}; + +export const parseGroundCodeSharePath = ( + pathname: string, +): GroundCodeSharePath | null => { + const segments = pathname + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); + + if (segments.length === 1) { + const [rawCode] = segments; + if (!rawCode) return null; + if (reservedRootSegments.has(rawCode) || bodyPrefixes.has(rawCode)) { + return null; + } + const code = decodeURIComponent(rawCode); + if (!isCodeLikeShareSegment(code)) return null; + return { + body: "earth", + code, + }; + } + + const [rawBody, rawCode] = segments; + if (segments.length === 2 && rawBody && rawCode && bodyPrefixes.has(rawBody)) { + const code = decodeURIComponent(rawCode); + if (!isCodeLikeShareSegment(code)) return null; + return { + body: rawBody as CelestialBody, + code, + }; + } + + return null; +}; diff --git a/apps/web/lib/grid-system/types.ts b/apps/web/lib/grid-system/types.ts index 57c93816..1afd09e1 100644 --- a/apps/web/lib/grid-system/types.ts +++ b/apps/web/lib/grid-system/types.ts @@ -1,5 +1,3 @@ -import { Dispatch, SetStateAction } from "react"; - export interface Coordinates { lat: number; lng: number; diff --git a/apps/web/lib/grid-system/use-grid-system.ts b/apps/web/lib/grid-system/use-grid-system.ts index 792d7fa5..a9a3da35 100644 --- a/apps/web/lib/grid-system/use-grid-system.ts +++ b/apps/web/lib/grid-system/use-grid-system.ts @@ -18,8 +18,8 @@ export function useGridSystem( selectedArea: Coordinates | null, setSelectedArea: Dispatch>, mapOptions?: { - locationMode?: any; - setLocationMode?: (mode: any) => void; + locationMode?: string | number; + setLocationMode?: (mode: string | number) => void; placeDetailsVisible?: boolean; setPlaceDetailsVisible?: (visible: boolean) => void; setSelectedPlaceId?: (placeId: string | null) => void; @@ -33,7 +33,6 @@ export function useGridSystem( metersPerDegree !== undefined && metersPerDegree < 100000; const { gridLinesRef, - selectedRectangleRef, mapInstanceRef, clearAllGridLines, drawSelectedAreaRectangle, @@ -155,12 +154,15 @@ export function useGridSystem( isGridVisibleAtZoom, metersPerDegree, isPlanetaryGrid, + currentZoomRef, + gridLinesRef, + gridVisibleRef, + mapInstanceRef, ], ); // Create the grid cell click handler const handleGridCellClick = useGridCellClickHandler( - showGrid, isGridVisibleAtZoom, mapInstanceRef, setSelectedArea, diff --git a/apps/web/lib/grid-system/use-map-handlers.ts b/apps/web/lib/grid-system/use-map-handlers.ts index 6cc01eac..8aab753f 100644 --- a/apps/web/lib/grid-system/use-map-handlers.ts +++ b/apps/web/lib/grid-system/use-map-handlers.ts @@ -10,8 +10,8 @@ export function useMapEventHandlers( drawGrid: (mapInstance: google.maps.Map) => void, handleGridCellClick: (e: google.maps.MapMouseEvent) => void, options?: { - locationMode?: any; - setLocationMode?: (mode: any) => void; + locationMode?: string | number; + setLocationMode?: (mode: string | number) => void; placeDetailsVisible?: boolean; setPlaceDetailsVisible?: (visible: boolean) => void; setSelectedPlaceId?: (placeId: string | null) => void; @@ -22,9 +22,6 @@ export function useMapEventHandlers( ) { return useCallback( (mapInstance: google.maps.Map) => { - // Store initial zoom level - const currentZoomRef = { current: mapInstance.getZoom() || null }; - // Draw grid when map movement is complete mapInstance.addListener("idle", () => { // Only draw grid when showGrid is true @@ -41,13 +38,14 @@ export function useMapEventHandlers( } // Check if a POI was clicked - if ((e as any).placeId) { + const iconEvent = e as google.maps.IconMouseEvent; + if (iconEvent.placeId) { // Immediately stop the default POI click behavior - (e as google.maps.IconMouseEvent).stop(); + iconEvent.stop(); // A POI was clicked if (options?.setSelectedPlaceId) { - options.setSelectedPlaceId((e as any).placeId); + options.setSelectedPlaceId(iconEvent.placeId); } if (options?.setSelectedLocation) { options.setSelectedLocation(e.latLng || null); @@ -95,7 +93,6 @@ export function useMapEventHandlers( * Hook for grid cell click handler */ export function useGridCellClickHandler( - showGrid: boolean, isGridVisibleAtZoom: (zoom: number | undefined) => boolean, mapInstanceRef: React.MutableRefObject, setSelectedArea: React.Dispatch>, @@ -106,14 +103,9 @@ export function useGridCellClickHandler( (e: google.maps.MapMouseEvent) => { if (!e.latLng) return; - // Get map instance from event or from ref - let mapInstance = (e.latLng as any).map; + const mapInstance = mapInstanceRef.current; if (!mapInstance) { - if (mapInstanceRef.current) { - mapInstance = mapInstanceRef.current; - } else { - return; - } + return; } // Grid drawing is zoom-gated for performance, but address selection should @@ -153,7 +145,6 @@ export function useGridCellClickHandler( } }, [ - showGrid, isGridVisibleAtZoom, mapInstanceRef, setSelectedArea, diff --git a/apps/web/lib/i18n/i18n-context.tsx b/apps/web/lib/i18n/i18n-context.tsx index 73e40fad..ca40134e 100644 --- a/apps/web/lib/i18n/i18n-context.tsx +++ b/apps/web/lib/i18n/i18n-context.tsx @@ -4,14 +4,14 @@ import React, { createContext, useContext, useState, useEffect } from "react"; import { Locale, defaultLocale, locales } from "@/i18n"; // Message type -type Messages = Record; +type Messages = Record; // Context type interface I18nContextType { locale: Locale; messages: Messages; setLocale: (locale: Locale) => void; - t: (key: string, params?: Record) => string; + t: (key: string, params?: Record) => string; isChangingLanguage: boolean; // Add flag to indicate language is changing } @@ -31,14 +31,16 @@ const I18nContext = createContext(defaultContext); export const useI18n = () => useContext(I18nContext); // Get nested value from message -const getNestedValue = (obj: any, path: string): string => { +const getNestedValue = (obj: unknown, path: string): string => { const keys = path.split("."); - return keys.reduce((acc, key) => { + const value = keys.reduce((acc, key) => { if (acc && typeof acc === "object" && key in acc) { - return acc[key]; + return (acc as Record)[key]; } return path; // If key not found, return original path }, obj); + + return typeof value === "string" ? value : path; }; // Check if value is a valid locale @@ -172,7 +174,10 @@ export const I18nProvider: React.FC<{ }; // Translation function - const t = (key: string, params?: Record): string => { + const t = ( + key: string, + params?: Record + ): string => { let value = getNestedValue(messages, key); // Parameter replacement @@ -222,7 +227,7 @@ export const I18nProvider: React.FC<{ // Load messages const loadedMessages = ( await import(`@/messages/${localeToUse}/index.json`) - ).default; + ).default as Messages; setMessages(loadedMessages); setLocaleState(localeToUse); diff --git a/apps/web/messages/cn/index.json b/apps/web/messages/cn/index.json index 4add2043..6b34526d 100644 --- a/apps/web/messages/cn/index.json +++ b/apps/web/messages/cn/index.json @@ -30,10 +30,16 @@ "loading": "加载中...", "coordinates": { "title": "选定区域坐标", - "copy": "复制" + "copy": "复制", + "share": "分享", + "copied": "Ground Code 已复制", + "shareCopied": "分享 URL 已复制" }, "search": { - "placeholder": "搜索位置" + "placeholder": "搜索 Ground Code、区域或地点", + "submit": "搜索", + "noResults": "未找到匹配的 Ground Code 或区域", + "error": "搜索失败" }, "groundCode": "地面代码", "encoding": "编码中", diff --git a/apps/web/messages/en/index.json b/apps/web/messages/en/index.json index d85425e6..c0471cb1 100644 --- a/apps/web/messages/en/index.json +++ b/apps/web/messages/en/index.json @@ -30,10 +30,16 @@ "loading": "Loading...", "coordinates": { "title": "Selected Area Coordinates", - "copy": "Copy" + "copy": "Copy", + "share": "Share", + "copied": "Ground Code copied", + "shareCopied": "Share URL copied" }, "search": { - "placeholder": "Search for a location" + "placeholder": "Search Ground Code, region, or place", + "submit": "Search", + "noResults": "No matching Ground Code or region found", + "error": "Search failed" }, "groundCode": "Ground Code", "encoding": "Encoding", diff --git a/apps/web/messages/ja/index.json b/apps/web/messages/ja/index.json index a8b2b5c5..1c3ebed8 100644 --- a/apps/web/messages/ja/index.json +++ b/apps/web/messages/ja/index.json @@ -30,10 +30,16 @@ "loading": "読み込み中...", "coordinates": { "title": "選択範囲の座標", - "copy": "コピー" + "copy": "コピー", + "share": "共有", + "copied": "Ground Codeをコピーしました", + "shareCopied": "共有URLをコピーしました" }, "search": { - "placeholder": "場所を検索" + "placeholder": "Ground Code、地域、場所を検索", + "submit": "検索", + "noResults": "一致する Ground Code または地域が見つかりません", + "error": "検索に失敗しました" }, "groundCode": "グラウンドコード", "encoding": "エンコード中", diff --git a/apps/web/messages/ko/index.json b/apps/web/messages/ko/index.json index 56a33590..a85ea98b 100644 --- a/apps/web/messages/ko/index.json +++ b/apps/web/messages/ko/index.json @@ -30,10 +30,16 @@ "loading": "로딩 중...", "coordinates": { "title": "선택된 영역 좌표", - "copy": "복사" + "copy": "복사", + "share": "공유", + "copied": "그라운드 코드가 복사되었습니다", + "shareCopied": "공유 URL이 복사되었습니다" }, "search": { - "placeholder": "위치 검색" + "placeholder": "그라운드 코드, 지역, 장소 검색", + "submit": "검색", + "noResults": "일치하는 그라운드 코드나 지역을 찾지 못했습니다", + "error": "검색에 실패했습니다" }, "groundCode": "그라운드 코드", "encoding": "인코딩 중", diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 8dd427b5..d2e50504 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -18,6 +18,8 @@ const nextConfig: NextConfig = { process.env.NEXT_PUBLIC_CESIUM_MOON_ASSET_ID, NEXT_PUBLIC_CESIUM_MARS_ASSET_ID: process.env.NEXT_PUBLIC_CESIUM_MARS_ASSET_ID, + NEXT_PUBLIC_GROUND_CODES_API_URL: + process.env.NEXT_PUBLIC_GROUND_CODES_API_URL, }, eslint: { ignoreDuringBuilds: true, diff --git a/apps/web/package.json b/apps/web/package.json index 3f9d0113..73f02156 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,6 +9,10 @@ "start": "next start", "lint": "next lint", "check-types": "tsc --noEmit", + "test": "bun test ./lib ./app", + "test:e2e": "playwright test", + "test:e2e:prod": "playwright test --config=playwright.production.config.ts", + "qa:visual": "playwright test e2e/visual-qa.spec.ts --config=playwright.production.config.ts", "pages:build": "npx @cloudflare/next-on-pages", "preview": "pnpm run pages:build && wrangler pages dev", "deploy": "pnpm run pages:build && wrangler pages deploy" @@ -27,6 +31,7 @@ "devDependencies": { "@cloudflare/next-on-pages": "^1.13.9", "@eslint/eslintrc": "^3", + "@playwright/test": "^1.56.1", "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@tailwindcss/postcss": "^4", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 00000000..30a1dc71 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + expect: { + timeout: 10_000, + }, + use: { + baseURL: "http://127.0.0.1:3001", + trace: "on-first-retry", + }, + webServer: [ + { + command: "pnpm --filter api-ground-codes start", + cwd: "../..", + url: "http://127.0.0.1:3000/readyz", + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + { + command: + "NEXT_PUBLIC_GROUND_CODES_API_URL=http://127.0.0.1:3000 pnpm --filter web exec next dev --turbopack -p 3001", + cwd: "../..", + url: "http://127.0.0.1:3001", + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + ], + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/apps/web/playwright.production.config.ts b/apps/web/playwright.production.config.ts new file mode 100644 index 00000000..88b58168 --- /dev/null +++ b/apps/web/playwright.production.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 90_000, + expect: { + timeout: 15_000, + }, + use: { + baseURL: "http://127.0.0.1:3001", + trace: "on-first-retry", + }, + webServer: [ + { + command: "pnpm --filter api-ground-codes start", + cwd: "../..", + url: "http://127.0.0.1:3000/readyz", + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + { + command: + "NEXT_PUBLIC_GROUND_CODES_API_URL=http://127.0.0.1:3000 pnpm --filter web build && NEXT_PUBLIC_GROUND_CODES_API_URL=http://127.0.0.1:3000 pnpm --filter web exec next start -p 3001", + cwd: "../..", + url: "http://127.0.0.1:3001", + reuseExistingServer: false, + timeout: 120_000, + }, + ], + projects: [ + { + name: "desktop-chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "mobile-chromium", + use: { ...devices["Pixel 5"] }, + }, + ], +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index b0cacf41..daca4a3e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -19,5 +19,5 @@ "next.config.js", ".next/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "**/*.test.ts"] } diff --git a/packages/geoint/src/index.ts b/packages/geoint/src/index.ts index b44ee337..af089694 100644 --- a/packages/geoint/src/index.ts +++ b/packages/geoint/src/index.ts @@ -30,6 +30,7 @@ export const load = async (loadRegions: string[] = []) => { for (const file of files) { const regionName = file.replace(".index", ""); if (loadRegions.length > 0 && !loadRegions.includes(regionName)) continue; + if (regionIndexes[regionName] && regionLevels[regionName]) continue; const filePath = path.join(dbPath, file); const index = KDBush.from(fs.readFileSync(filePath).buffer); diff --git a/packages/ground-codes/src/index.ts b/packages/ground-codes/src/index.ts index 438ed832..f4bac949 100644 --- a/packages/ground-codes/src/index.ts +++ b/packages/ground-codes/src/index.ts @@ -1,4 +1,8 @@ -import { findClosestRegion, findRegionByCodeOrName } from "./region.js"; +import { + findClosestRegion, + findRegionByCodeOrName, + findRegionsByQuery, +} from "./region.js"; import { CelestialBody, calculateCoordinateDiff, @@ -248,6 +252,7 @@ export { setSpiralCacheEnabled, findClosestRegion, findRegionByCodeOrName, + findRegionsByQuery, encodeByWordSet, decodeByWordSet, calculateCoordinateDiff, diff --git a/packages/ground-codes/src/region.ts b/packages/ground-codes/src/region.ts index 988a1c8b..0b0a67c5 100644 --- a/packages/ground-codes/src/region.ts +++ b/packages/ground-codes/src/region.ts @@ -13,10 +13,12 @@ export interface Region { body?: CelestialBody; regionLevel?: number; distanceKm?: number; + population?: number; } const DEFAULT_REGION_2_FALLBACK_DISTANCE_KM = 100; const DEFAULT_MARS_REGION_2_FALLBACK_DISTANCE_KM = 100; +const regionDataCache = new Map>(); const loadRegions = async ( regionLevel: number, @@ -24,46 +26,81 @@ const loadRegions = async ( body: CelestialBody = "earth", ): Promise => { const normalizedLanguage = language?.toLowerCase(); + const cacheKey = `${body}:${regionLevel}:${normalizedLanguage ?? "english"}`; + const cached = regionDataCache.get(cacheKey); + if (cached) return cached; - if (body === "moon") { - if (regionLevel !== 2) throw new Error("Moon supports region level 2"); - if (normalizedLanguage === "korean") { - const module = - // @ts-ignore - await import( - "@ground-codes/geoint/region-dist/region-2-moon-korean.json" - ); - return module.default as Region[]; - } - if (normalizedLanguage === "chinese") { - const module = - // @ts-ignore - await import( - "@ground-codes/geoint/region-dist/region-2-moon-chinese.json" - ); - return module.default as Region[]; - } - if (normalizedLanguage === "japanese") { + const load = async () => { + if (body === "moon") { + if (regionLevel !== 2) throw new Error("Moon supports region level 2"); + if (normalizedLanguage === "korean") { + const module = + // @ts-ignore + await import( + "@ground-codes/geoint/region-dist/region-2-moon-korean.json" + ); + return module.default as Region[]; + } + if (normalizedLanguage === "chinese") { + const module = + // @ts-ignore + await import( + "@ground-codes/geoint/region-dist/region-2-moon-chinese.json" + ); + return module.default as Region[]; + } + if (normalizedLanguage === "japanese") { + const module = + // @ts-ignore + await import( + "@ground-codes/geoint/region-dist/region-2-moon-japanese.json" + ); + return module.default as Region[]; + } const module = // @ts-ignore - await import( - "@ground-codes/geoint/region-dist/region-2-moon-japanese.json" - ); + await import("@ground-codes/geoint/region-dist/region-2-moon.json"); return module.default as Region[]; } - const module = - // @ts-ignore - await import("@ground-codes/geoint/region-dist/region-2-moon.json"); - return module.default as Region[]; - } - if (body === "mars") { - if (regionLevel === 3) { + if (body === "mars") { + if (regionLevel === 3) { + if (normalizedLanguage === "korean") { + const module = + // @ts-ignore + await import( + "@ground-codes/geoint/region-dist/region-3-mars-korean.json" + ); + return module.default as Region[]; + } + if (normalizedLanguage === "chinese") { + const module = + // @ts-ignore + await import( + "@ground-codes/geoint/region-dist/region-3-mars-chinese.json" + ); + return module.default as Region[]; + } + if (normalizedLanguage === "japanese") { + const module = + // @ts-ignore + await import( + "@ground-codes/geoint/region-dist/region-3-mars-japanese.json" + ); + return module.default as Region[]; + } + const module = + // @ts-ignore + await import("@ground-codes/geoint/region-dist/region-3-mars.json"); + return module.default as Region[]; + } + if (regionLevel !== 2) + throw new Error("Mars supports region levels 2 and 3"); if (normalizedLanguage === "korean") { const module = // @ts-ignore await import( - "@ground-codes/geoint/region-dist/region-3-mars-korean.json" + "@ground-codes/geoint/region-dist/region-2-mars-korean.json" ); return module.default as Region[]; } @@ -71,7 +108,7 @@ const loadRegions = async ( const module = // @ts-ignore await import( - "@ground-codes/geoint/region-dist/region-3-mars-chinese.json" + "@ground-codes/geoint/region-dist/region-2-mars-chinese.json" ); return module.default as Region[]; } @@ -79,115 +116,96 @@ const loadRegions = async ( const module = // @ts-ignore await import( - "@ground-codes/geoint/region-dist/region-3-mars-japanese.json" + "@ground-codes/geoint/region-dist/region-2-mars-japanese.json" ); return module.default as Region[]; } const module = // @ts-ignore - await import("@ground-codes/geoint/region-dist/region-3-mars.json"); - return module.default as Region[]; - } - if (regionLevel !== 2) - throw new Error("Mars supports region levels 2 and 3"); - if (normalizedLanguage === "korean") { - const module = - // @ts-ignore - await import( - "@ground-codes/geoint/region-dist/region-2-mars-korean.json" - ); - return module.default as Region[]; - } - if (normalizedLanguage === "chinese") { - const module = - // @ts-ignore - await import( - "@ground-codes/geoint/region-dist/region-2-mars-chinese.json" - ); + await import("@ground-codes/geoint/region-dist/region-2-mars.json"); return module.default as Region[]; } - if (normalizedLanguage === "japanese") { - const module = - // @ts-ignore - await import( - "@ground-codes/geoint/region-dist/region-2-mars-japanese.json" - ); - return module.default as Region[]; - } - const module = - // @ts-ignore - await import("@ground-codes/geoint/region-dist/region-2-mars.json"); - return module.default as Region[]; - } - if (regionLevel === 1) { - const module = - // @ts-ignore - await import("@ground-codes/geoint/region-dist/region-1.json"); - return module.default as Region[]; - } - - if (regionLevel === 2) { - if (!language || normalizedLanguage === "english") { - const module = - // @ts-ignore - await import("@ground-codes/geoint/region-dist/region-2.json"); - return module.default as Region[]; - } - if (normalizedLanguage === "korean") { + if (regionLevel === 1) { const module = // @ts-ignore - await import("@ground-codes/geoint/region-dist/region-2-korean.json"); + await import("@ground-codes/geoint/region-dist/region-1.json"); return module.default as Region[]; } - if (normalizedLanguage === "chinese") { - const module = - // @ts-ignore - await import("@ground-codes/geoint/region-dist/region-2-chinese.json"); - return module.default as Region[]; - } - if (normalizedLanguage === "japanese") { - const module = - // @ts-ignore - await import( - "@ground-codes/geoint/region-dist/region-2-japanese.json" - ); - return module.default as Region[]; - } - throw new Error(`Invalid language: ${language}`); - } - if (regionLevel === 3) { - if (!language || normalizedLanguage === "english") { - const module = - // @ts-ignore - await import("@ground-codes/geoint/region-dist/region-3.json"); - return module.default as Region[]; - } - if (normalizedLanguage === "korean") { - const module = - // @ts-ignore - await import("@ground-codes/geoint/region-dist/region-3-korean.json"); - return module.default as Region[]; - } - if (normalizedLanguage === "chinese") { - const module = - // @ts-ignore - await import("@ground-codes/geoint/region-dist/region-3-chinese.json"); - return module.default as Region[]; + if (regionLevel === 2) { + if (!language || normalizedLanguage === "english") { + const module = + // @ts-ignore + await import("@ground-codes/geoint/region-dist/region-2.json"); + return module.default as Region[]; + } + if (normalizedLanguage === "korean") { + const module = + // @ts-ignore + await import("@ground-codes/geoint/region-dist/region-2-korean.json"); + return module.default as Region[]; + } + if (normalizedLanguage === "chinese") { + const module = + // @ts-ignore + await import( + "@ground-codes/geoint/region-dist/region-2-chinese.json" + ); + return module.default as Region[]; + } + if (normalizedLanguage === "japanese") { + const module = + // @ts-ignore + await import( + "@ground-codes/geoint/region-dist/region-2-japanese.json" + ); + return module.default as Region[]; + } + throw new Error(`Invalid language: ${language}`); } - if (normalizedLanguage === "japanese") { - const module = - // @ts-ignore - await import( - "@ground-codes/geoint/region-dist/region-3-japanese.json" - ); - return module.default as Region[]; + + if (regionLevel === 3) { + if (!language || normalizedLanguage === "english") { + const module = + // @ts-ignore + await import("@ground-codes/geoint/region-dist/region-3.json"); + return module.default as Region[]; + } + if (normalizedLanguage === "korean") { + const module = + // @ts-ignore + await import("@ground-codes/geoint/region-dist/region-3-korean.json"); + return module.default as Region[]; + } + if (normalizedLanguage === "chinese") { + const module = + // @ts-ignore + await import( + "@ground-codes/geoint/region-dist/region-3-chinese.json" + ); + return module.default as Region[]; + } + if (normalizedLanguage === "japanese") { + const module = + // @ts-ignore + await import( + "@ground-codes/geoint/region-dist/region-3-japanese.json" + ); + return module.default as Region[]; + } + throw new Error(`Invalid language: ${language}`); } - throw new Error(`Invalid language: ${language}`); - } - throw new Error(`Invalid region level: ${regionLevel}`); + throw new Error(`Invalid region level: ${regionLevel}`); + }; + + const promise = load().catch((error) => { + regionDataCache.delete(cacheKey); + throw error; + }); + regionDataCache.set(cacheKey, promise); + return promise; }; const findClosestInRegions = ( @@ -343,80 +361,123 @@ export const findRegionByCodeOrName = async ( name?: string; code?: string; } | null> => { + const matches = await findRegionsByQuery(codeOrName, { + ...options, + maxResults: 1, + }); + + return matches[0] ?? null; +}; + +export const findRegionsByQuery = async ( + codeOrName: string, + options?: { + regionLevel?: number; + language?: SupportedLanguage; + body?: CelestialBody; + maxResults?: number; + }, +): Promise< + Array<{ + lat: number; + lng: number; + regionLevel?: number; + body?: CelestialBody; + name?: string; + code?: string; + }> +> => { if (!codeOrName || codeOrName.trim() === "") { - return null; + return []; } try { - const { regionLevel = 2, language, body = "earth" } = options ?? {}; + const { + regionLevel = 2, + language, + body = "earth", + maxResults = 5, + } = options ?? {}; - const regions = await loadRegions(regionLevel, language, body); - - // Normalize the search term for case-insensitive comparison const normalizedSearch = codeOrName.toLowerCase().trim(); + const results: Array<{ + lat: number; + lng: number; + regionLevel?: number; + body?: CelestialBody; + name?: string; + code?: string; + }> = []; + const seen = new Set(); + + const addMatches = async (candidateRegionLevel: number) => { + const regions = await loadRegions(candidateRegionLevel, language, body); + const exactMatches = regions.filter( + (region) => + region.code.toLowerCase() === normalizedSearch || + region.name.toLowerCase() === normalizedSearch, + ); + const partialMatches = regions + .filter( + (region) => + !exactMatches.includes(region) && + (region.code.toLowerCase().includes(normalizedSearch) || + region.name.toLowerCase().includes(normalizedSearch)), + ) + .sort((a, b) => { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + const aCode = a.code.toLowerCase(); + const bCode = b.code.toLowerCase(); + const aRank = aName.startsWith(normalizedSearch) + ? 0 + : aCode.startsWith(normalizedSearch) + ? 1 + : 2; + const bRank = bName.startsWith(normalizedSearch) + ? 0 + : bCode.startsWith(normalizedSearch) + ? 1 + : 2; + + return ( + aRank - bRank || + (b.population ?? 0) - (a.population ?? 0) || + a.name.length - b.name.length + ); + }); + + for (const region of [...exactMatches, ...partialMatches]) { + const key = `${body}:${candidateRegionLevel}:${region.code}:${region.name}`; + if (seen.has(key)) continue; + seen.add(key); + results.push({ + name: region.name, + code: region.code, + lat: region.lat, + lng: region.long, + body, + regionLevel: candidateRegionLevel, + }); + if (results.length >= maxResults) return; + } + }; - // Find the region that matches the code or name - const matchedRegion = regions.find( - (region) => - region.code.toLowerCase() === normalizedSearch || - region.name.toLowerCase() === normalizedSearch, - ); - - if (matchedRegion) { - return { - name: matchedRegion.name, - code: matchedRegion.code, - lat: matchedRegion.lat, - lng: matchedRegion.long, - body, - regionLevel, - }; - } + await addMatches(regionLevel); + if (results.length >= maxResults) return results; if ((body === "earth" || body === "mars") && regionLevel === 2) { const fallbackLevels = body === "mars" ? [3] : [1, 3]; for (const fallbackLevel of fallbackLevels) { - const fallbackMatch = ( - await loadRegions(fallbackLevel, language, body) - ).find( - (region) => - region.code.toLowerCase() === normalizedSearch || - region.name.toLowerCase() === normalizedSearch, - ); - - if (fallbackMatch) { - return { - name: fallbackMatch.name, - code: fallbackMatch.code, - lat: fallbackMatch.lat, - lng: fallbackMatch.long, - body, - regionLevel: fallbackLevel, - }; - } + await addMatches(fallbackLevel); + if (results.length >= maxResults) return results; } } - // If no exact match is found, try to find a region whose name contains the search term - const partialMatch = regions.find((region) => - region.name.toLowerCase().includes(normalizedSearch), - ); - - if (partialMatch) { - return { - name: partialMatch.name, - code: partialMatch.code, - lat: partialMatch.lat, - lng: partialMatch.long, - body, - regionLevel, - }; - } - - return null; + return results; } catch (e) { console.error("Error finding region by code or name:", e); - return null; + return []; } }; diff --git a/packages/ground-codes/tsup.config.ts b/packages/ground-codes/tsup.config.ts index 083ba693..0069aea0 100644 --- a/packages/ground-codes/tsup.config.ts +++ b/packages/ground-codes/tsup.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ }, }, splitting: true, + minify: true, sourcemap: false, clean: true, }); diff --git a/packages/ui/src/components/grid-canvas.tsx b/packages/ui/src/components/grid-canvas.tsx index 4ee7e6f0..ee88f0e9 100644 --- a/packages/ui/src/components/grid-canvas.tsx +++ b/packages/ui/src/components/grid-canvas.tsx @@ -1,6 +1,12 @@ "use client"; import { useEffect, useRef } from "react"; +const COLORS = { + background: "#000000", + label: "#FFFFFF", + gridLine: "#FFFFFF", +}; + export interface GridCanvasProps { gridSize?: number; canvasSize?: number; @@ -33,13 +39,6 @@ export default function GridCanvas({ const canvasRef = useRef(null); - // 색상 팔레트 - const COLORS = { - background: "#000000", - label: "#FFFFFF", - gridLine: "#FFFFFF", - }; - const drawArrow = ( ctx: CanvasRenderingContext2D, fromX: number, diff --git a/packages/ui/src/components/spiral-viewer.tsx b/packages/ui/src/components/spiral-viewer.tsx index c21b4823..b4514b5f 100644 --- a/packages/ui/src/components/spiral-viewer.tsx +++ b/packages/ui/src/components/spiral-viewer.tsx @@ -187,7 +187,7 @@ export default function SpiralViewer({ : "text-gray-400" }`} > - N'= + N'= {getCoordinates && getNFromCoordinates ? (() => { const coord = getCoordinates(n); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f5a4ffc..d8226a8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,7 +59,7 @@ importers: version: link:../../packages/ui next: specifier: 15.5.18 - version: 15.5.18(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: specifier: ^19.0.0 version: 19.0.0 @@ -120,10 +120,10 @@ importers: version: 1.141.0 next: specifier: 15.5.18 - version: 15.5.18(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next-intl: specifier: ^4.0.2 - version: 4.0.2(next@15.5.18(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(typescript@5.8.2) + version: 4.0.2(next@15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(typescript@5.8.2) react: specifier: ^19.0.0 version: 19.0.0 @@ -140,6 +140,9 @@ importers: '@eslint/eslintrc': specifier: ^3 version: 3.3.0 + '@playwright/test': + specifier: ^1.56.1 + version: 1.60.0 '@repo/eslint-config': specifier: workspace:* version: link:../../packages/eslint-config @@ -1684,6 +1687,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.0': resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} @@ -3360,6 +3368,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4368,6 +4381,16 @@ packages: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -6349,6 +6372,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@radix-ui/number@1.1.0': {} '@radix-ui/primitive@1.1.1': {} @@ -8128,6 +8155,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -8828,17 +8858,17 @@ snapshots: negotiator@1.0.0: {} - next-intl@4.0.2(next@15.5.18(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(typescript@5.8.2): + next-intl@4.0.2(next@15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(typescript@5.8.2): dependencies: '@formatjs/intl-localematcher': 0.5.10 negotiator: 1.0.0 - next: 15.5.18(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: 19.0.0 use-intl: 4.0.2(react@19.0.0) optionalDependencies: typescript: 5.8.2 - next@15.5.18(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next@15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.5.18 '@swc/helpers': 0.5.15 @@ -8857,6 +8887,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 15.5.18 '@next/swc-win32-x64-msvc': 15.5.18 '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.60.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -9078,6 +9109,14 @@ snapshots: pirates@4.0.6: {} + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.3)(tsx@4.19.3)(yaml@2.7.0): diff --git a/turbo.json b/turbo.json index 1143ccee..49c60367 100644 --- a/turbo.json +++ b/turbo.json @@ -1,17 +1,35 @@ { "$schema": "https://turbo.build/schema.json", "ui": "tui", + "globalEnv": [ + "GOOGLE_MAPS_NODEJS_API_KEY", + "OPENWEATHER_API_KEY", + "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY", + "NEXT_PUBLIC_API_BASE_URL" + ], "tasks": { "build": { "dependsOn": ["^build"], + "env": [ + "GOOGLE_MAPS_NODEJS_API_KEY", + "OPENWEATHER_API_KEY", + "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY", + "NEXT_PUBLIC_API_BASE_URL" + ], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, "lint": { - "dependsOn": ["^lint"] + "dependsOn": ["^lint"], + "env": [ + "GOOGLE_MAPS_NODEJS_API_KEY", + "OPENWEATHER_API_KEY", + "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY", + "NEXT_PUBLIC_API_BASE_URL" + ] }, "check-types": { - "dependsOn": ["^check-types"] + "dependsOn": ["^build", "^check-types"] }, "dev": { "cache": false,