From 37200f4be12b6b2dd9d56cb78907a1eb82f1af60 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 18 Jul 2026 00:08:42 +0200 Subject: [PATCH 1/5] backend: use log level --- apps/backend/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 3dce74a5..d68643f8 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -55,7 +55,9 @@ app.use("*", async (c, next) => { await next(); } finally { const processingTime = Math.round(performance.now() - startedAt); - logger.info(`[${timestamp}] ${c.req.method} ${c.req.path} ${processingTime}ms`); + if (config.LOG_LEVEL?.trim().toLowerCase() === "info") { + logger.info(`[${timestamp}] ${c.req.method} ${c.req.path} ${processingTime}ms`); + } } }); From 4ee3b1f9bfdab33b85176116eb5a93f4af917b07 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 18 Jul 2026 00:31:34 +0200 Subject: [PATCH 2/5] do not render dropdown in placeholder widget --- .../dashboard/DashboardLayoutTemplate.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx b/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx index feb9a727..329bc3ee 100644 --- a/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx +++ b/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx @@ -517,16 +517,19 @@ export default function DashboardLayoutTemplate({ } else if (typeof h === "number") { heightStyle = `${h}px`; } - return renderWidgetMenuWrapper({ - baseKey, - wrapperClass, - style: heightStyle ? { height: heightStyle } : undefined, - children: renderWidget({ - type: "placeholder", - params: cfg.params, - className: "h-full w-full", - }), - }); + return ( +
+ {renderWidget({ + type: "placeholder", + params: cfg.params, + className: "h-full w-full", + })} +
+ ); } case "main-clock": { const ref = getHeightRefCallback("main-clock"); From c37c88a5578f4f213e440ffc429529ae6c57b574 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 18 Jul 2026 01:21:13 +0200 Subject: [PATCH 3/5] update docs --- README.md | 62 +------------------------------------- docs/Configuration.md | 70 +++++++++++++++++++++++++++++++++++++++++++ docs/Dev.md | 33 ++++++++++++++++++++ 3 files changed, 104 insertions(+), 61 deletions(-) create mode 100644 docs/Configuration.md create mode 100644 docs/Dev.md diff --git a/README.md b/README.md index 650a4128..cc638ee3 100755 --- a/README.md +++ b/README.md @@ -34,67 +34,7 @@ bun run dev The PocketBase backend can be either run using the aio container (default method) or connected to an external database (not recommended, migrations may fail) ## Configuration -You can use the following environment variables for the all-in-one container - default values will also work: - -### Core Settings -| Name | Required | Default Value | Description | -| --- | --- | --- | --- | -| INSTANCE_NAME / NEXT_PUBLIC_INSTANCE_NAME | No | Dashwise | The dashboard's display name | -| PB_URL / NEXT_PUBLIC_PB_URL | No (if start pocketbase is true) | `http://127.0.0.1:8090` | PocketBase URL. Backend uses `PB_URL`, frontend uses `NEXT_PUBLIC_PB_URL` | -| START_POCKETBASE | No | `true` | Start the bundled PocketBase process; set to `false` to use an external instance | -| PB_BINARY_PATH | No | - | Path to PocketBase binary (default: `pocketbase/pocketbase`) | -| PORT | No | `3000` | HTTP port for the backend server | - -### Authentication -| Name | Required | Default Value | Description | -| --- | --- | --- | --- | -| PB_ADMIN_EMAIL | Yes | `default@dashwise.local` | Email of the PocketBase admin user | -| PB_ADMIN_PASSWORD | Yes | `DashwiseIsAwesome` | Password of the PocketBase admin user | - -### URLs -| Name | Required | Default Value | Description | -| --- | --- | --- | --- | -| NEXT_PUBLIC_APP_URL / APP_BASE_URL | No | `http://localhost:3000` | Public URL of the application | -| NEXT_PUBLIC_BACKEND_URL | No | - | Backend URL for frontend API calls (fallback: window.location.origin in production) | -| DASHWISE_URL | No | - | Internal Dashwise URL for jobs container communication | - -### Appearance & Features -| Name | Required | Default Value | Description | -| --- | --- | --- | --- | -| NEXT_PUBLIC_DEFAULT_BG_URL / DEFAULT_BG_URL | No | `/dashboard-wallpaper.png` | Default background URL for new users | -| NEXT_PUBLIC_ENABLE_SSO / ENABLE_SSO | No | `false` | Enable Single Sign-On (SSO) via OIDC | -| NEXT_PUBLIC_DISABLE_USER_SIGNUP / DISABLE_USER_SIGNUP | No | `false` | Disable user self-registration | - -### SSL/TLS -| Name | Required | Default Value | Description | -| --- | --- | --- | --- | -| NEXT_PUBLIC_INTEGRATIONS_ENABLE_SSL / ALLOW_INSECURE_CERTS_FOR_INTEGRATION_URLS | No | `false` | Allow insecure SSL certificates for integration URLs | -| ALLOW_SSL | No | `false` | Enable SSL for internal service communication | -| LOG_LEVEL / BACKEND_LOG_LEVEL | No | - | Backend log level (debug, info, warn, error) | - -### Jobs & Background Processing -| Name | Required | Default Value | Description | -| --- | --- | --- | --- | -| JOBS_URL / NEXT_PUBLIC_JOBS_URL | No | `http://127.0.0.1:3001` | URL of the jobs service | -| JOBS_WEBHOOK_ENABLE / NEXT_PUBLIC_JOBS_WEBHOOK_ENABLE | No | `false` | Explicitly enable the jobs webhook. Set to `1` or `true` to force-enable | -| JOBS_WEBHOOK_URL | No | `http://jobs:3000/api/forward-notifications` | Webhook URL for forwarding notifications to jobs | -| JOBS_MONITORING_RETRY_AFTER | No | `5000` | Time in milliseconds to wait before retrying a failed monitoring ping | - -### Scheduled Jobs (Cron Expressions) -| Name | Required | Default Value | Description | -| --- | --- | --- | --- | -| SEARCHITEMS_SCHEDULE | No | `*/10 * * * *` | Interval for search item indexing job | -| ENABLE_ICONS_REFRESH | No | `false` | Enable automatic icon refresh job | -| PULL_ICONS_SCHEDULE | No | `0 */6 * * *` | How often the icons refresh job runs | -| MONITORING_INDEXER_SCHEDULE | No | `*/10 * * * *` | How often the monitoring indexer runs | -| MONITORING_RUNNER_SCHEDULE | No | `*/1 * * * *` | How often the monitoring runner executes | -| UPDATE_CHECK_SCHEDULE | No | `0 2 * * *` | Schedule for update check job | -| FEED_BUILDING_SCHEDULE | No | `*/30 * * * *` | Schedule for news feed building job | -| NOTIFICATION_FORWARDER_SCHEDULE | No | `* * * * *` | Schedule for notification forwarder job | -| DEFAULT_INTEGRATIONS_SCHEDULE | No | `0 4 * * *` | Schedule for default integrations sync | -| PAGECONFIG_CLEANUP_SCHEDULE | No | `0 5 * * *` | Schedule for page config cleanup | -| MONITORING_OUTLIER_THRESHOLD_TYPE | No | `relative` | Threshold type for monitoring outliers (`absolute` or `relative`) | -| MONITORING_OUTLIER_THRESHOLD_VALUE | No | `50` | Threshold value for monitoring outliers | +See the [configuration reference](docs/Configuration.md) for all supported environment variables. ## Tech Stack Frontend: React SPA bundled with Bun diff --git a/docs/Configuration.md b/docs/Configuration.md new file mode 100644 index 00000000..411e6283 --- /dev/null +++ b/docs/Configuration.md @@ -0,0 +1,70 @@ +# Configuration + +You can use the following environment variables for the all-in-one container; the default values also work. + +### Core Settings + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| INSTANCE_NAME / NEXT_PUBLIC_INSTANCE_NAME | No | Dashwise | The dashboard's display name | +| PB_URL / NEXT_PUBLIC_PB_URL | No (if start pocketbase is true) | `http://127.0.0.1:8090` | PocketBase URL. Backend uses `PB_URL`, frontend uses `NEXT_PUBLIC_PB_URL` | +| START_POCKETBASE | No | `true` | Start the bundled PocketBase process; set to `false` to use an external instance | +| PB_BINARY_PATH | No | - | Path to PocketBase binary (default: `pocketbase/pocketbase`) | +| PORT | No | `3000` | HTTP port for the backend server | + +### Authentication + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| PB_ADMIN_EMAIL | Yes | `default@dashwise.local` | Email of the PocketBase admin user | +| PB_ADMIN_PASSWORD | Yes | `DashwiseIsAwesome` | Password of the PocketBase admin user | + +### URLs + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| NEXT_PUBLIC_APP_URL / APP_BASE_URL | No | `http://localhost:3000` | Public URL of the application | +| NEXT_PUBLIC_BACKEND_URL | No | - | Backend URL for frontend API calls (fallback: window.location.origin in production) | +| DASHWISE_URL | No | - | Internal Dashwise URL for jobs container communication | + +### Appearance & Features + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| NEXT_PUBLIC_DEFAULT_BG_URL / DEFAULT_BG_URL | No | `/dashboard-wallpaper.png` | Default background URL for new users | +| NEXT_PUBLIC_ENABLE_SSO / ENABLE_SSO | No | `false` | Enable Single Sign-On (SSO) via OIDC | +| NEXT_PUBLIC_DISABLE_USER_SIGNUP / DISABLE_USER_SIGNUP | No | `false` | Disable user self-registration | + +### SSL/TLS + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| NEXT_PUBLIC_INTEGRATIONS_ENABLE_SSL / ALLOW_INSECURE_CERTS_FOR_INTEGRATION_URLS | No | `false` | Allow insecure SSL certificates for integration URLs | +| ALLOW_SSL | No | `false` | Enable SSL for internal service communication | +| LOG_LEVEL / BACKEND_LOG_LEVEL | No | - | Backend log level (debug, info, warn, error) | + +### Jobs & Background Processing + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| JOBS_URL / NEXT_PUBLIC_JOBS_URL | No | `http://127.0.0.1:3001` | URL of the jobs service | +| JOBS_WEBHOOK_ENABLE / NEXT_PUBLIC_JOBS_WEBHOOK_ENABLE | No | `false` | Explicitly enable the jobs webhook. Set to `1` or `true` to force-enable | +| JOBS_WEBHOOK_URL | No | `http://jobs:3000/api/forward-notifications` | Webhook URL for forwarding notifications to jobs | +| JOBS_MONITORING_RETRY_AFTER | No | `5000` | Time in milliseconds to wait before retrying a failed monitoring ping | + +### Scheduled Jobs (Cron Expressions) + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| SEARCHITEMS_SCHEDULE | No | `*/10 * * * *` | Interval for search item indexing job | +| ENABLE_ICONS_REFRESH | No | `false` | Enable automatic icon refresh job | +| PULL_ICONS_SCHEDULE | No | `0 */6 * * *` | How often the icons refresh job runs | +| MONITORING_INDEXER_SCHEDULE | No | `*/10 * * * *` | How often the monitoring indexer runs | +| MONITORING_RUNNER_SCHEDULE | No | `*/1 * * * *` | How often the monitoring runner executes | +| UPDATE_CHECK_SCHEDULE | No | `0 2 * * *` | Schedule for update check job | +| FEED_BUILDING_SCHEDULE | No | `*/30 * * * *` | Schedule for news feed building job | +| NOTIFICATION_FORWARDER_SCHEDULE | No | `* * * * *` | Schedule for notification forwarder job | +| DEFAULT_INTEGRATIONS_SCHEDULE | No | `0 4 * * *` | Schedule for default integrations sync | +| PAGECONFIG_CLEANUP_SCHEDULE | No | `0 5 * * *` | Schedule for page config cleanup | +| MONITORING_OUTLIER_THRESHOLD_TYPE | No | `relative` | Threshold type for monitoring outliers (`absolute` or `relative`) | +| MONITORING_OUTLIER_THRESHOLD_VALUE | No | `50` | Threshold value for monitoring outliers | diff --git a/docs/Dev.md b/docs/Dev.md new file mode 100644 index 00000000..bccd5514 --- /dev/null +++ b/docs/Dev.md @@ -0,0 +1,33 @@ + +## Generated artifacts + +Generated artifacts are committed so the checked-in frontend can consume the API +contract without running generators. Regenerate all of them with: + +```sh +bun run generate +``` + +Do not edit generated TypeScript files directly; they are marked with a +`DO NOT EDIT` header. `bun run build` only builds and never regenerates files. + +| Output | Authoritative input | Generated by | +| --- | --- | --- | +| `apps/web/public/openapi.json` | `apps/backend/openapi.yaml` | `bun run generate` | +| `packages/api-types/src/openapi.ts` | `apps/backend/openapi.yaml` | `bun run generate` | +| `apps/web/src/lib/api/**` | `apps/backend/openapi.yaml` and `apps/web/openapi-ts.config.ts` | `bun run generate` | + +`openapi.json` is JSON and therefore cannot carry a comment header. The +PocketBase schema authority is `pocketbase/migrations`; generated PocketBase +record types have not yet been introduced, so storage records must stay behind +backend mapping code and must not be exposed through HTTP routes. + +New or materially changed backend endpoints should use a feature-local Zod +contract. The migrated system endpoints demonstrate the pattern in +`apps/backend/src/features/system/system.contract.ts`: the route validates its +input at the Hono boundary, validates responses outside production, and +`bun run generate` writes the marked OpenAPI section from that schema. + +Before opening a contract change, run `bun run generate && git diff --exit-code` +followed by `bun run check`. `bun run test:contract` validates the checked-in +OpenAPI contract independently of the generated artifacts. \ No newline at end of file From 15ca2de3d3d335dda857217c6a2c428452cd5b24 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sun, 19 Jul 2026 13:20:55 +0200 Subject: [PATCH 4/5] Add executable API contract pipeline --- .github/PULL_REQUEST_TEMPLATE.md | 7 ++ .github/workflows/contracts.yml | 29 ++++++ apps/backend/openapi.yaml | 28 +++++- apps/backend/package.json | 3 +- .../src/features/system/system.contract.ts | 21 +++++ apps/backend/src/lib/http/contract.ts | 34 +++++++ apps/backend/src/routes/shared.ts | 19 +++- apps/backend/src/routes/system.route.ts | 10 +- apps/web/package.json | 9 +- apps/web/public/openapi.json | 36 +++++++- apps/web/src/lib/api/client.gen.ts | 1 + apps/web/src/lib/api/client/client.gen.ts | 1 + apps/web/src/lib/api/client/index.ts | 1 + apps/web/src/lib/api/client/types.gen.ts | 1 + apps/web/src/lib/api/client/utils.gen.ts | 1 + apps/web/src/lib/api/core/auth.gen.ts | 1 + .../src/lib/api/core/bodySerializer.gen.ts | 1 + apps/web/src/lib/api/core/params.gen.ts | 1 + .../src/lib/api/core/pathSerializer.gen.ts | 1 + .../lib/api/core/queryKeySerializer.gen.ts | 1 + .../src/lib/api/core/serverSentEvents.gen.ts | 1 + apps/web/src/lib/api/core/types.gen.ts | 1 + apps/web/src/lib/api/core/utils.gen.ts | 1 + apps/web/src/lib/api/index.ts | 1 + apps/web/src/lib/api/sdk.gen.ts | 1 + apps/web/src/lib/api/types.gen.ts | 11 ++- package.json | 12 ++- packages/api-types/src/openapi.ts | 27 +++++- scripts/contract-smoke.ts | 21 +++++ scripts/generate-openapi-contracts.ts | 92 +++++++++++++++++++ scripts/generate-openapi-json.ts | 15 +++ scripts/mark-generated-artifacts.ts | 31 +++++++ test/contract-smoke.test.ts | 20 ++++ test/system-contract.test.ts | 34 +++++++ 34 files changed, 455 insertions(+), 19 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/contracts.yml create mode 100644 apps/backend/src/features/system/system.contract.ts create mode 100644 apps/backend/src/lib/http/contract.ts create mode 100644 scripts/contract-smoke.ts create mode 100644 scripts/generate-openapi-contracts.ts create mode 100644 scripts/mark-generated-artifacts.ts create mode 100644 test/contract-smoke.test.ts create mode 100644 test/system-contract.test.ts diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..689f63d6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +## Contract or migration changes + +Complete this section only when the pull request changes an HTTP contract or PocketBase migration. + +- [ ] Migration updated (if persistent storage changed) +- [ ] Generated artifacts refreshed with `bun run generate` +- [ ] Consumer verified diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 00000000..14c4aaf7 --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,29 @@ +name: Contract pipeline + +on: + pull_request: + push: + branches: [dev, release] + +jobs: + generated-artifacts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.8 + - run: bun install --frozen-lockfile + - run: bun run generate + - run: git diff --exit-code -- apps/web/public/openapi.json apps/web/src/lib/api packages/api-types/src/openapi.ts + + contract-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.8 + - run: bun install --frozen-lockfile + - run: bun run check + - run: bun run test:contract diff --git a/apps/backend/openapi.yaml b/apps/backend/openapi.yaml index 0e4a1028..3142d001 100644 --- a/apps/backend/openapi.yaml +++ b/apps/backend/openapi.yaml @@ -23,6 +23,8 @@ tags: - name: widgets - name: weather paths: + # DO NOT EDIT — generated by bun run generate from system.contract.ts + # BEGIN GENERATED SYSTEM CONTRACTS /appConfig: get: tags: @@ -30,7 +32,12 @@ paths: summary: Get application config responses: "200": - $ref: "#/components/responses/JsonOk" + description: OK + content: + application/json: + schema: + type: object + additionalProperties: {} /appInfo: get: tags: @@ -38,7 +45,24 @@ paths: summary: Get application info responses: "200": - $ref: "#/components/responses/JsonOk" + description: OK + content: + application/json: + schema: + type: object + properties: + updateAvailable: + type: boolean + currentAppVersion: + type: string + userSignupDisabled: + type: boolean + required: + - updateAvailable + - currentAppVersion + - userSignupDisabled + additionalProperties: false + # END GENERATED SYSTEM CONTRACTS /auth/callback: get: tags: diff --git a/apps/backend/package.json b/apps/backend/package.json index 44b9a5cd..f9dad4c8 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,7 +11,8 @@ "scripts": { "dev": "bun --hot src/index.ts", "start": "NODE_ENV=production bun src/index.ts", - "generate:openapi": "bun ../../scripts/generate-openapi-json.ts && bunx openapi-typescript openapi.yaml -o ../../packages/api-types/src/openapi.ts", + "generate": "bun --cwd ../.. run generate", + "generate:openapi": "bun run generate", "build": "bunx -p typescript@5.9.3 tsc -p tsconfig.json", "lint": "eslint . --quiet", "lint:all": "eslint ." diff --git a/apps/backend/src/features/system/system.contract.ts b/apps/backend/src/features/system/system.contract.ts new file mode 100644 index 00000000..ae9d51c6 --- /dev/null +++ b/apps/backend/src/features/system/system.contract.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +import { defineGetContract, emptyQuerySchema } from "../../lib/http/contract"; + +export const appConfigContract = defineGetContract({ + summary: "Get application config", + tags: ["app"], + query: emptyQuerySchema, + response: z.record(z.unknown()), +}); + +export const appInfoContract = defineGetContract({ + summary: "Get application info", + tags: ["app"], + query: emptyQuerySchema, + response: z.object({ + updateAvailable: z.boolean(), + currentAppVersion: z.string(), + userSignupDisabled: z.boolean(), + }), +}); diff --git a/apps/backend/src/lib/http/contract.ts b/apps/backend/src/lib/http/contract.ts new file mode 100644 index 00000000..617533f1 --- /dev/null +++ b/apps/backend/src/lib/http/contract.ts @@ -0,0 +1,34 @@ +import { z, type ZodTypeAny } from "zod"; + +export type GetContract = { + summary: string; + tags: string[]; + query: ZodTypeAny; + response: Response; +}; + +export function defineGetContract( + contract: GetContract, +) { + return contract; +} + +export function shouldValidateContractResponses() { + return Bun.env.NODE_ENV !== "production"; +} + +export function validateContractResponse( + contract: GetContract, + value: unknown, +) { + return shouldValidateContractResponses() ? contract.response.parse(value) : value; +} + +export function validateContractQuery( + contract: GetContract, + value: unknown, +) { + return contract.query.parse(value); +} + +export const emptyQuerySchema = z.object({}).strict(); diff --git a/apps/backend/src/routes/shared.ts b/apps/backend/src/routes/shared.ts index e180f48d..662c4e06 100644 --- a/apps/backend/src/routes/shared.ts +++ b/apps/backend/src/routes/shared.ts @@ -6,6 +6,12 @@ import type { Context } from "hono"; import { ApiActionError, requireUserAuth } from "../lib/data/auth"; import { z } from "zod"; +import { + type GetContract, + validateContractQuery, + validateContractResponse, +} from "../lib/http/contract"; + import { config } from "../lib/config"; import { defaultHomeConfig } from "@dashwise/assets"; @@ -77,6 +83,17 @@ export function withJson) = imp }; } +/** Validate request input at the route boundary and responses outside production. */ +export function withGetContract( + contract: GetContract, + handler: (c: Context) => Promise | unknown, +) { + return withJson(async (c) => { + validateContractQuery(contract, c.req.query()); + return validateContractResponse(contract, await handler(c)); + }); +} + export function readAuthToken(c: Context) { const authorization = c.req.header("authorization") ?? c.req.header("Authorization"); if (authorization?.toLowerCase().startsWith("bearer ")) { @@ -111,4 +128,4 @@ export function routeRedirectTarget(c: Context) { } return config.DASHWISE_URL; -} \ No newline at end of file +} diff --git a/apps/backend/src/routes/system.route.ts b/apps/backend/src/routes/system.route.ts index fde6c3b9..cb4ea1de 100644 --- a/apps/backend/src/routes/system.route.ts +++ b/apps/backend/src/routes/system.route.ts @@ -5,12 +5,16 @@ import { getLocations } from "../lib/data/misc"; import { runPullIcons } from "../lib/data/jobs"; import { jobsApi, validateJobsBasicAuth } from "../jobs/index"; -import { readAuthToken, requireAuth, withJson } from "./shared"; +import { readAuthToken, requireAuth, withGetContract, withJson } from "./shared"; +import { + appConfigContract, + appInfoContract, +} from "../features/system/system.contract"; const systemRoute = new Hono(); -systemRoute.get("/api/v1/appConfig", withJson(() => getAppConfig())); -systemRoute.get("/api/v1/appInfo", withJson(() => getAppInfo())); +systemRoute.get("/api/v1/appConfig", withGetContract(appConfigContract, () => getAppConfig())); +systemRoute.get("/api/v1/appInfo", withGetContract(appInfoContract, () => getAppInfo())); systemRoute.get( "/api/v1/locations", diff --git a/apps/web/package.json b/apps/web/package.json index f6bdd92d..56cce59e 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,13 +4,14 @@ "private": true, "scripts": { "dev": "bunx vite --host 0.0.0.0 --port 5173", - "build": "bun run generate:openapi && bun run generate:api-sdk && bunx vite build", + "build": "bunx vite build", "start": "bunx vite preview --host 0.0.0.0 --port 4173", "lint": "eslint . --quiet", "lint:all": "eslint .", - "generate:openapi": "bun ../../scripts/generate-openapi-json.ts && bunx openapi-typescript ../../apps/backend/openapi.yaml -o ../../packages/api-types/src/openapi.ts", - "generate:api-sdk": "bunx openapi-ts -f openapi-ts.config.ts", - "generate:api-types": "bun run generate:openapi" + "generate": "bun --cwd ../.. run generate", + "generate:openapi": "bun run generate", + "generate:api-sdk": "bun run generate", + "generate:api-types": "bun run generate" }, "dependencies": { "@dashwise/api-types": "workspace:*", diff --git a/apps/web/public/openapi.json b/apps/web/public/openapi.json index e77aa1ff..be16c9b9 100644 --- a/apps/web/public/openapi.json +++ b/apps/web/public/openapi.json @@ -69,7 +69,15 @@ "summary": "Get application config", "responses": { "200": { - "$ref": "#/components/responses/JsonOk" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + } } } } @@ -82,7 +90,31 @@ "summary": "Get application info", "responses": { "200": { - "$ref": "#/components/responses/JsonOk" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "updateAvailable": { + "type": "boolean" + }, + "currentAppVersion": { + "type": "string" + }, + "userSignupDisabled": { + "type": "boolean" + } + }, + "required": [ + "updateAvailable", + "currentAppVersion", + "userSignupDisabled" + ], + "additionalProperties": false + } + } + } } } } diff --git a/apps/web/src/lib/api/client.gen.ts b/apps/web/src/lib/api/client.gen.ts index 5276b939..9eea176f 100644 --- a/apps/web/src/lib/api/client.gen.ts +++ b/apps/web/src/lib/api/client.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts import { type Client, type ClientOptions, type Config, createClient, createConfig } from './client'; diff --git a/apps/web/src/lib/api/client/client.gen.ts b/apps/web/src/lib/api/client/client.gen.ts index fc3f037f..f0314715 100644 --- a/apps/web/src/lib/api/client/client.gen.ts +++ b/apps/web/src/lib/api/client/client.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts import { createSseClient } from '../core/serverSentEvents.gen'; diff --git a/apps/web/src/lib/api/client/index.ts b/apps/web/src/lib/api/client/index.ts index 8c693310..88cd26cc 100644 --- a/apps/web/src/lib/api/client/index.ts +++ b/apps/web/src/lib/api/client/index.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts export type { Auth } from '../core/auth.gen'; diff --git a/apps/web/src/lib/api/client/types.gen.ts b/apps/web/src/lib/api/client/types.gen.ts index 193646cd..f7fc2806 100644 --- a/apps/web/src/lib/api/client/types.gen.ts +++ b/apps/web/src/lib/api/client/types.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts import type { Auth } from '../core/auth.gen'; diff --git a/apps/web/src/lib/api/client/utils.gen.ts b/apps/web/src/lib/api/client/utils.gen.ts index d4a72843..93a7cff2 100644 --- a/apps/web/src/lib/api/client/utils.gen.ts +++ b/apps/web/src/lib/api/client/utils.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts import { getAuthToken } from '../core/auth.gen'; diff --git a/apps/web/src/lib/api/core/auth.gen.ts b/apps/web/src/lib/api/core/auth.gen.ts index c6636644..3ba823ee 100644 --- a/apps/web/src/lib/api/core/auth.gen.ts +++ b/apps/web/src/lib/api/core/auth.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts export type AuthToken = string | undefined; diff --git a/apps/web/src/lib/api/core/bodySerializer.gen.ts b/apps/web/src/lib/api/core/bodySerializer.gen.ts index 67daca60..8ade56b9 100644 --- a/apps/web/src/lib/api/core/bodySerializer.gen.ts +++ b/apps/web/src/lib/api/core/bodySerializer.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; diff --git a/apps/web/src/lib/api/core/params.gen.ts b/apps/web/src/lib/api/core/params.gen.ts index 0f50047b..7f66cc81 100644 --- a/apps/web/src/lib/api/core/params.gen.ts +++ b/apps/web/src/lib/api/core/params.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts type Slot = 'body' | 'headers' | 'path' | 'query'; diff --git a/apps/web/src/lib/api/core/pathSerializer.gen.ts b/apps/web/src/lib/api/core/pathSerializer.gen.ts index fab1ed4b..e9391f4c 100644 --- a/apps/web/src/lib/api/core/pathSerializer.gen.ts +++ b/apps/web/src/lib/api/core/pathSerializer.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} diff --git a/apps/web/src/lib/api/core/queryKeySerializer.gen.ts b/apps/web/src/lib/api/core/queryKeySerializer.gen.ts index 773b0650..5453b27f 100644 --- a/apps/web/src/lib/api/core/queryKeySerializer.gen.ts +++ b/apps/web/src/lib/api/core/queryKeySerializer.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts /** diff --git a/apps/web/src/lib/api/core/serverSentEvents.gen.ts b/apps/web/src/lib/api/core/serverSentEvents.gen.ts index ddf3c4d1..a1db60b5 100644 --- a/apps/web/src/lib/api/core/serverSentEvents.gen.ts +++ b/apps/web/src/lib/api/core/serverSentEvents.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts import type { Config } from './types.gen'; diff --git a/apps/web/src/lib/api/core/types.gen.ts b/apps/web/src/lib/api/core/types.gen.ts index c657c859..48ca2109 100644 --- a/apps/web/src/lib/api/core/types.gen.ts +++ b/apps/web/src/lib/api/core/types.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts import type { Auth, AuthToken } from './auth.gen'; diff --git a/apps/web/src/lib/api/core/utils.gen.ts b/apps/web/src/lib/api/core/utils.gen.ts index af56e071..ca818a65 100644 --- a/apps/web/src/lib/api/core/utils.gen.ts +++ b/apps/web/src/lib/api/core/utils.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index 16255397..ae5e1b3c 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts export { deleteAuthDeleteAccount, deleteIntegrationsById, deleteLinksItemsByLinkId, deleteMonitorsById, deleteNotificationsForwarders, deleteNotificationsTopics, deleteNotificationsTopicTokens, getAppConfig, getAppInfo, getAuthCallback, getAuthSso, getGlanceables, getGlanceablesByIntegration, getIntegrations, getIntegrationsCaldavEvents, getIntegrationsConsumerData, getIntegrationsWidgetProperties, getJobsPullIcons, getJobsSearchItems, getLinksCollections, getLinksFolders, getLinksHome, getLinksHomeGroups, getLinksItems, getLinksTags, getLocations, getMonitoringStatus, getMonitors, getMonitorsById, getNews, getNewsFeed, getNewsFeedMetadata, getNewsFeedRecordsById, getNewsFeedRefresh, getNewsFeeds, getNewsFeedsById, getNewsSubscriptions, getNewsSubscriptionsByIdJson, getNotifications, getNotificationsForwarders, getNotificationsTopics, getNotificationsTopicTokens, getPageConfig, getPageConfigUserPages, getSearchItems, getSearchItemsFrequentlyUsed, getTestBookmarks, getWallpapers, getWeather, getWidgets, getWidgetsByIntegration, getWidgetsGlanceable, getWidgetsGlanceables, type Options, patchAuthUpdateUserProperty, postAuthChangePassword, postAuthLogin, postAuthMfa, postAuthSignup, postAuthValidateAuth, postIntegrations, postIntegrationsConsumerData, postIntegrationsProxyAction, postIntegrationsTestEndpoint, postLinksCollections, postLinksFolders, postLinksHomeGroups, postLinksItems, postLinksReorder, postLinksTags, postMonitoringStatus, postMonitors, postNewsFeedRecords, postNewsFeedRecordsById, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFixMissingTitles, postNotifications, postNotificationsByTopic, postNotificationsForwarders, postNotificationsMarkAsRead, postNotificationsTest, postNotificationsTopics, postNotificationsTopicTokens, postPageConfigHome, postPageConfigIntegrationData, postPageConfigMigrateLegacy, postSearchItemsUsageStats, postWallpapers, putIntegrationsById, putLinksCollectionsByCollectionId, putLinksFoldersByFolderIdIcon, putLinksItemsByLinkId, putLinksTagsByTagId, putMonitorsById, putNotificationsForwarders, putPageConfig } from './sdk.gen'; diff --git a/apps/web/src/lib/api/sdk.gen.ts b/apps/web/src/lib/api/sdk.gen.ts index 7c91bbed..29f19dd6 100644 --- a/apps/web/src/lib/api/sdk.gen.ts +++ b/apps/web/src/lib/api/sdk.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; diff --git a/apps/web/src/lib/api/types.gen.ts b/apps/web/src/lib/api/types.gen.ts index cea47460..cfe18787 100644 --- a/apps/web/src/lib/api/types.gen.ts +++ b/apps/web/src/lib/api/types.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { @@ -25,7 +26,9 @@ export type GetAppConfigResponses = { /** * OK */ - 200: GenericObject; + 200: { + [key: string]: unknown; + }; }; export type GetAppConfigResponse = GetAppConfigResponses[keyof GetAppConfigResponses]; @@ -41,7 +44,11 @@ export type GetAppInfoResponses = { /** * OK */ - 200: GenericObject; + 200: { + updateAvailable: boolean; + currentAppVersion: string; + userSignupDisabled: boolean; + }; }; export type GetAppInfoResponse = GetAppInfoResponses[keyof GetAppInfoResponses]; diff --git a/package.json b/package.json index 184b44d5..9b689e6a 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,19 @@ "dev:testenv": "./scripts/dev-testenv.sh", "dev:backend": "bun --cwd apps/backend --hot src/index.ts", "dev:web": "cd apps/web && bunx dotenv-cli -e ../../.env -- bunx vite --host 0.0.0.0 --port 5173", - "generate:openapi": "bun scripts/generate-openapi-json.ts && bunx openapi-typescript apps/backend/openapi.yaml -o packages/api-types/src/openapi.ts", + "generate": "bun run generate:openapi-document && bun run generate:api-types && bun run generate:api-sdk && bun run mark-generated", + "generate:openapi-document": "bun scripts/generate-openapi-contracts.ts && bun scripts/generate-openapi-json.ts", + "generate:api-types": "cd apps/web && bunx openapi-typescript ../../apps/backend/openapi.yaml -o ../../packages/api-types/src/openapi.ts", "generate:api-sdk": "cd apps/web && bunx openapi-ts -f openapi-ts.config.ts", + "generate:openapi": "bun run generate", + "mark-generated": "bun scripts/mark-generated-artifacts.ts", "build": "bun run --workspaces --if-present build", "lint": "eslint . --quiet", "lint:all": "eslint .", - "typecheck": "bun run --workspaces --if-present typecheck" + "typecheck": "bun run --workspaces --if-present typecheck", + "test": "bun test", + "test:contract": "bun scripts/contract-smoke.ts", + "check:generated": "bun run generate && git diff --exit-code -- apps/web/public/openapi.json apps/web/src/lib/api packages/api-types/src/openapi.ts", + "check": "bun run lint && bun run typecheck && bun run check:generated" } } diff --git a/packages/api-types/src/openapi.ts b/packages/api-types/src/openapi.ts index 7e8312c8..3ecf9ccc 100644 --- a/packages/api-types/src/openapi.ts +++ b/packages/api-types/src/openapi.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate /** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. @@ -21,7 +22,17 @@ export interface paths { }; requestBody?: never; responses: { - 200: components["responses"]["JsonOk"]; + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; }; }; put?: never; @@ -49,7 +60,19 @@ export interface paths { }; requestBody?: never; responses: { - 200: components["responses"]["JsonOk"]; + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + updateAvailable: boolean; + currentAppVersion: string; + userSignupDisabled: boolean; + }; + }; + }; }; }; put?: never; diff --git a/scripts/contract-smoke.ts b/scripts/contract-smoke.ts new file mode 100644 index 00000000..e7f1a48a --- /dev/null +++ b/scripts/contract-smoke.ts @@ -0,0 +1,21 @@ +import YAML from "yaml"; + +const specificationPath = new URL("../apps/backend/openapi.yaml", import.meta.url); +const specification = YAML.parse(await Bun.file(specificationPath).text()); + +if ( + !specification || + typeof specification !== "object" || + typeof specification.openapi !== "string" || + !specification.info || + typeof specification.info.title !== "string" || + typeof specification.info.version !== "string" || + !specification.paths || + typeof specification.paths !== "object" +) { + throw new Error("OpenAPI contract smoke test failed: invalid apps/backend/openapi.yaml"); +} + +console.log( + `OpenAPI contract smoke test passed (${Object.keys(specification.paths).length} paths).`, +); diff --git a/scripts/generate-openapi-contracts.ts b/scripts/generate-openapi-contracts.ts new file mode 100644 index 00000000..f171e50f --- /dev/null +++ b/scripts/generate-openapi-contracts.ts @@ -0,0 +1,92 @@ +import YAML from "yaml"; + +import { + appConfigContract, + appInfoContract, +} from "../apps/backend/src/features/system/system.contract"; +import type { GetContract } from "../apps/backend/src/lib/http/contract"; + +const sourcePath = new URL("../apps/backend/openapi.yaml", import.meta.url); +const beginMarker = " # BEGIN GENERATED SYSTEM CONTRACTS"; +const endMarker = " # END GENERATED SYSTEM CONTRACTS"; + +function zodToOpenApiSchema(schema: any): Record { + const typeName = schema?._def?.typeName; + + switch (typeName) { + case "ZodString": + return { type: "string" }; + case "ZodBoolean": + return { type: "boolean" }; + case "ZodNumber": + return { type: "number" }; + case "ZodUnknown": + case "ZodAny": + return {}; + case "ZodRecord": + return { + type: "object", + additionalProperties: zodToOpenApiSchema(schema._def.valueType), + }; + case "ZodObject": { + const shape = schema._def.shape(); + return { + type: "object", + properties: Object.fromEntries( + Object.entries(shape).map(([key, value]) => [key, zodToOpenApiSchema(value)]), + ), + required: Object.keys(shape), + additionalProperties: schema._def.unknownKeys === "passthrough", + }; + } + default: + throw new Error(`Unsupported Zod type in OpenAPI adapter: ${typeName}`); + } +} + +function toOpenApiPath(contract: GetContract) { + return { + get: { + tags: contract.tags, + summary: contract.summary, + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: zodToOpenApiSchema(contract.response), + }, + }, + }, + }, + }, + }; +} + +const generatedPaths = { + "/appConfig": toOpenApiPath(appConfigContract), + "/appInfo": toOpenApiPath(appInfoContract), +}; +const pathsYaml = YAML.stringify(generatedPaths) + .trimEnd() + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); +const generatedBlock = [ + " # DO NOT EDIT — generated by bun run generate from system.contract.ts", + beginMarker, + pathsYaml, + endMarker, +].join("\n"); + +const source = await Bun.file(sourcePath).text(); +const generatedBlockPattern = / {2}# DO NOT EDIT — (?: {2}# DO NOT EDIT — )*generated by bun run generate from system\.contract\.ts\n {2}# BEGIN GENERATED SYSTEM CONTRACTS[\s\S]*?\n {2}# END GENERATED SYSTEM CONTRACTS/; + +if (!generatedBlockPattern.test(source)) { + throw new Error("OpenAPI generated-system-contract markers are missing or malformed."); +} + +await Bun.write( + sourcePath, + source.replace(generatedBlockPattern, generatedBlock), +); diff --git a/scripts/generate-openapi-json.ts b/scripts/generate-openapi-json.ts index 6fcbf1ea..b7167a9e 100644 --- a/scripts/generate-openapi-json.ts +++ b/scripts/generate-openapi-json.ts @@ -6,4 +6,19 @@ const outputPath = new URL("../apps/web/public/openapi.json", import.meta.url); const source = await Bun.file(sourcePath).text(); const document = YAML.parse(source); +if ( + !document || + typeof document !== "object" || + typeof document.openapi !== "string" || + !document.info || + typeof document.info.title !== "string" || + typeof document.info.version !== "string" || + !document.paths || + typeof document.paths !== "object" +) { + throw new Error( + "apps/backend/openapi.yaml is not a valid OpenAPI document: openapi, info.title, info.version, and paths are required.", + ); +} + await Bun.write(outputPath, `${JSON.stringify(document, null, "\t")}\n`); diff --git a/scripts/mark-generated-artifacts.ts b/scripts/mark-generated-artifacts.ts new file mode 100644 index 00000000..3e3d175f --- /dev/null +++ b/scripts/mark-generated-artifacts.ts @@ -0,0 +1,31 @@ +import { readdir } from "node:fs/promises"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dir, ".."); +const header = "// DO NOT EDIT — generated by bun run generate\n"; +const generatedTypeScriptPaths = [ + resolve(root, "packages/api-types/src/openapi.ts"), +]; + +async function collectTypeScriptFiles(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + await collectTypeScriptFiles(path); + } else if (entry.isFile() && entry.name.endsWith(".ts")) { + generatedTypeScriptPaths.push(path); + } + } +} + +await collectTypeScriptFiles(resolve(root, "apps/web/src/lib/api")); + +for (const path of generatedTypeScriptPaths) { + const file = Bun.file(path); + const contents = await file.text(); + const marked = contents.startsWith(header) ? contents : `${header}${contents}`; + + if (marked !== contents) { + await Bun.write(path, marked); + } +} diff --git a/test/contract-smoke.test.ts b/test/contract-smoke.test.ts new file mode 100644 index 00000000..bc2163fc --- /dev/null +++ b/test/contract-smoke.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test"; +import YAML from "yaml"; + +test("the checked-in OpenAPI contract has its required document fields", async () => { + const specificationPath = new URL("../apps/backend/openapi.yaml", import.meta.url); + const specification = YAML.parse(await Bun.file(specificationPath).text()); + + expect(specification).toBeObject(); + expect(specification.openapi).toBeString(); + expect(specification.info?.title).toBeString(); + expect(specification.info?.version).toBeString(); + expect(specification.paths).toBeObject(); + expect(Object.keys(specification.paths).length).toBeGreaterThan(0); + expect(specification.paths["/appInfo"].get.responses["200"].content["application/json"].schema.properties) + .toEqual({ + updateAvailable: { type: "boolean" }, + currentAppVersion: { type: "string" }, + userSignupDisabled: { type: "boolean" }, + }); +}); diff --git a/test/system-contract.test.ts b/test/system-contract.test.ts new file mode 100644 index 00000000..594be663 --- /dev/null +++ b/test/system-contract.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; + +import { + appConfigContract, + appInfoContract, +} from "../apps/backend/src/features/system/system.contract"; +import { + validateContractQuery, + validateContractResponse, +} from "../apps/backend/src/lib/http/contract"; + +test("system contract rejects unexpected query parameters", () => { + expect(() => validateContractQuery(appInfoContract, { unexpected: "value" })).toThrow(); +}); + +test("system contract validates the app-info response shape", () => { + expect(() => validateContractResponse(appInfoContract, { + updateAvailable: false, + currentAppVersion: "v1.0.0", + userSignupDisabled: false, + })).not.toThrow(); + expect(() => validateContractResponse(appInfoContract, { + updateAvailable: "no", + currentAppVersion: "v1.0.0", + userSignupDisabled: false, + })).toThrow(); +}); + +test("system app-config contract permits arbitrary configuration fields", () => { + expect(() => validateContractResponse(appConfigContract, { + instanceName: "Dashwise", + nested: { enabled: true }, + })).not.toThrow(); +}); From 66cb7377284660f7440b1f27e917dd90cc4bb559 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sun, 19 Jul 2026 13:25:43 +0200 Subject: [PATCH 5/5] frame (mobile nohover): add cooldown to show controls --- .../src/components/dashboard/Screensaver.tsx | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/dashboard/Screensaver.tsx b/apps/web/src/components/dashboard/Screensaver.tsx index b80e09fd..8221ed07 100644 --- a/apps/web/src/components/dashboard/Screensaver.tsx +++ b/apps/web/src/components/dashboard/Screensaver.tsx @@ -7,6 +7,8 @@ import { renderWidget } from "../widgets/Widget"; import AppIcon from "@dashwise/app-icon"; import { fetchWallpaperBlob } from "@/lib/apiClient"; +const TOUCH_CONTROLS_HIDE_DELAY = 2500; + export default function Screensaver( { active, onExit }: { active: boolean; onExit: () => void }, ) { @@ -20,6 +22,28 @@ export default function Screensaver( const [frameBackgrounds, setFrameBackgrounds] = useState>({}); const [scrollLeft, setScrollLeft] = useState(0); const scrollRef = useRef(null); + const controlsHideTimeoutRef = useRef | null>(null); + + const clearControlsHideTimeout = () => { + if (controlsHideTimeoutRef.current) { + clearTimeout(controlsHideTimeoutRef.current); + controlsHideTimeoutRef.current = null; + } + }; + + const scheduleTouchControlsHide = () => { + clearControlsHideTimeout(); + controlsHideTimeoutRef.current = setTimeout(() => { + setIsHovering(false); + controlsHideTimeoutRef.current = null; + }, TOUCH_CONTROLS_HIDE_DELAY); + }; + + useEffect(() => () => { + if (controlsHideTimeoutRef.current) { + clearTimeout(controlsHideTimeoutRef.current); + } + }, []); useEffect(() => { fetch("/fonts/index.json") @@ -192,8 +216,29 @@ export default function Screensaver(
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} + onMouseLeave={() => { + clearControlsHideTimeout(); + setIsHovering(false); + }} onMouseMove={() => setIsHovering(true)} + onPointerEnter={(event) => { + if (event.pointerType === "touch") { + setIsHovering(true); + scheduleTouchControlsHide(); + } + }} + onPointerDown={(event) => { + if (event.pointerType === "touch") { + setIsHovering(true); + scheduleTouchControlsHide(); + } + }} + onPointerMove={(event) => { + if (event.pointerType === "touch") { + setIsHovering(true); + scheduleTouchControlsHide(); + } + }} >