Add TypeScript frontend build system with shared components

- Set up esbuild for fast TypeScript compilation of frontend code
- Create SharedHeader component with factories for main/admin/privacy pages
- Create SharedFooter component with standard and minimal variants
- Add frontend build scripts (build:frontend, watch:frontend, dev:full)
- Configure TypeScript for browser environment with DOM types
- Add example page demonstrating shared component usage
- Update .gitignore to exclude compiled frontend files

Benefits:
- Type-safe frontend components
- Consistent headers/footers across all pages
- Single source of truth for common UI elements
- Built-in i18n and theme toggle support

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Code 2025-07-07 19:46:19 -04:00
parent 7bee175003
commit 5151e87824
11 changed files with 986 additions and 4 deletions

1
.gitignore vendored
View file

@ -30,6 +30,7 @@ Thumbs.db
# Generated files
public/style.css
public/style.css.map
public/dist/
# TypeScript build outputs
dist/

503
package-lock.json generated
View file

@ -23,6 +23,7 @@
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/leaflet": "^1.9.19",
"@types/node": "^24.0.10",
"@types/node-cron": "^3.0.11",
"@types/sqlite3": "^3.1.11",
@ -32,6 +33,7 @@
"@typescript-eslint/eslint-plugin": "^8.35.1",
"@typescript-eslint/parser": "^8.35.1",
"concurrently": "^9.2.0",
"esbuild": "^0.25.6",
"eslint": "^9.30.1",
"globals": "^16.3.0",
"jest": "^29.7.0",
@ -727,6 +729,448 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz",
"integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.6.tgz",
"integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz",
"integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.6.tgz",
"integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz",
"integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz",
"integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz",
"integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz",
"integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz",
"integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz",
"integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz",
"integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz",
"integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz",
"integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz",
"integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz",
"integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz",
"integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz",
"integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz",
"integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz",
"integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz",
"integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz",
"integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz",
"integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz",
"integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz",
"integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz",
"integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz",
"integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
@ -2053,6 +2497,13 @@
"@types/send": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@ -2327,6 +2778,16 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.19",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.19.tgz",
"integrity": "sha512-pB+n2daHcZPF2FDaWa+6B0a0mSDf4dPU35y5iTXsx7x/PzzshiX5atYiS1jlBn43X7XvM8AP+AB26lnSk0J4GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@ -4268,6 +4729,48 @@
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
"integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.6",
"@esbuild/android-arm": "0.25.6",
"@esbuild/android-arm64": "0.25.6",
"@esbuild/android-x64": "0.25.6",
"@esbuild/darwin-arm64": "0.25.6",
"@esbuild/darwin-x64": "0.25.6",
"@esbuild/freebsd-arm64": "0.25.6",
"@esbuild/freebsd-x64": "0.25.6",
"@esbuild/linux-arm": "0.25.6",
"@esbuild/linux-arm64": "0.25.6",
"@esbuild/linux-ia32": "0.25.6",
"@esbuild/linux-loong64": "0.25.6",
"@esbuild/linux-mips64el": "0.25.6",
"@esbuild/linux-ppc64": "0.25.6",
"@esbuild/linux-riscv64": "0.25.6",
"@esbuild/linux-s390x": "0.25.6",
"@esbuild/linux-x64": "0.25.6",
"@esbuild/netbsd-arm64": "0.25.6",
"@esbuild/netbsd-x64": "0.25.6",
"@esbuild/openbsd-arm64": "0.25.6",
"@esbuild/openbsd-x64": "0.25.6",
"@esbuild/openharmony-arm64": "0.25.6",
"@esbuild/sunos-x64": "0.25.6",
"@esbuild/win32-arm64": "0.25.6",
"@esbuild/win32-ia32": "0.25.6",
"@esbuild/win32-x64": "0.25.6"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",

View file

@ -13,23 +13,24 @@
"watch-css": "sass src/scss/main.scss public/style.css --watch --style=expanded --source-map",
"dev-with-css": "concurrently \"npm run watch-css\" \"npm run dev\"",
"dev-with-css:ts": "concurrently \"npm run watch-css\" \"npm run dev:ts\"",
"build": "npm run build:ts && npm run build-css && npm run copy-i18n",
"build": "npm run build:ts && npm run build-css && npm run build:frontend && npm run copy-i18n",
"build:ts": "tsc",
"copy-i18n": "mkdir -p dist/i18n/locales && cp -r src/i18n/locales/* dist/i18n/locales/",
"test": "jest --runInBand --forceExit",
"test:coverage": "jest --coverage",
"lint": "eslint src/ tests/",
"lint:fix": "eslint src/ tests/ --fix",
"postinstall": "npm run build-css"
"postinstall": "npm run build-css",
"build:frontend": "node scripts/build-frontend.js",
"watch:frontend": "node scripts/build-frontend.js --watch",
"dev:full": "concurrently \"npm run watch-css\" \"npm run watch:frontend\" \"npm run dev:ts\""
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.0.1",
"express": "^4.18.2",
"express-rate-limit": "^7.5.1",
"node-cron": "^3.0.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"
@ -38,6 +39,7 @@
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/leaflet": "^1.9.19",
"@types/node": "^24.0.10",
"@types/node-cron": "^3.0.11",
"@types/sqlite3": "^3.1.11",
@ -47,6 +49,7 @@
"@typescript-eslint/eslint-plugin": "^8.35.1",
"@typescript-eslint/parser": "^8.35.1",
"concurrently": "^9.2.0",
"esbuild": "^0.25.6",
"eslint": "^9.30.1",
"globals": "^16.3.0",
"jest": "^29.7.0",

View file

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shared Components Example</title>
<link rel="stylesheet" href="style.css">
<!-- Internationalization -->
<script src="i18n.js"></script>
</head>
<body>
<div class="container">
<!-- Container for shared header -->
<div id="header-container"></div>
<div class="content">
<div class="form-section">
<h2>Example Page Using Shared Components</h2>
<p>This page demonstrates the new TypeScript-based shared header and footer components.</p>
<h3>Benefits:</h3>
<ul>
<li>✅ Type-safe TypeScript components</li>
<li>✅ Consistent headers/footers across all pages</li>
<li>✅ Easy to maintain - change once, update everywhere</li>
<li>✅ Built-in i18n support</li>
<li>✅ Automatic theme toggle functionality</li>
</ul>
<h3>How to use:</h3>
<pre><code>// Include the compiled bundle
&lt;script src="dist/app-main.js"&gt;&lt;/script&gt;
// The shared components are automatically rendered
// into #header-container and #footer-container</code></pre>
</div>
</div>
<!-- Container for shared footer -->
<div id="footer-container"></div>
</div>
<!-- Include the compiled TypeScript bundle -->
<script src="dist/app-main.js"></script>
</body>
</html>

68
scripts/build-frontend.js Executable file
View file

@ -0,0 +1,68 @@
#!/usr/bin/env node
const esbuild = require('esbuild');
const path = require('path');
const fs = require('fs');
// Ensure output directory exists
const outdir = path.join(__dirname, '..', 'public', 'dist');
if (!fs.existsSync(outdir)) {
fs.mkdirSync(outdir, { recursive: true });
}
// Build configuration
const buildOptions = {
entryPoints: [
// Main app bundles
'src/frontend/app-main.ts',
'src/frontend/app-admin.ts',
'src/frontend/app-privacy.ts',
// Shared components will be imported by the above
],
bundle: true,
outdir: outdir,
format: 'iife', // Immediately Invoked Function Expression for browsers
platform: 'browser',
target: ['es2020'], // Modern browsers, matching our TypeScript target
sourcemap: process.env.NODE_ENV !== 'production',
minify: process.env.NODE_ENV === 'production',
loader: {
'.ts': 'ts',
},
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development')
},
// Don't bundle these - they'll be loaded from CDN
external: ['leaflet'],
};
// Build function
async function build() {
const isWatch = process.argv.includes('--watch');
try {
if (isWatch) {
// Watch mode for development
const ctx = await esbuild.context(buildOptions);
await ctx.watch();
console.log('👀 Watching for frontend changes...');
} else {
// One-time build
console.log('🔨 Building frontend...');
const result = await esbuild.build(buildOptions);
console.log('✅ Frontend build complete!');
if (result.errors.length > 0) {
console.error('❌ Build errors:', result.errors);
process.exit(1);
}
}
} catch (error) {
console.error('❌ Build failed:', error);
process.exit(1);
}
}
// Run the build
build();

37
src/frontend/app-admin.ts Normal file
View file

@ -0,0 +1,37 @@
/**
* Admin app entry point - uses shared components
*/
import { SharedHeader } from './components/SharedHeader';
import { SharedFooter } from './components/SharedFooter';
// Initialize shared components when DOM is ready
function initializeAdminApp() {
// Render header (only when logged in)
const adminSection = document.getElementById('admin-section');
if (adminSection && adminSection.style.display !== 'none') {
const header = SharedHeader.createAdminHeader();
// For admin, we need to replace the existing header
const existingHeader = document.querySelector('.admin-header');
if (existingHeader) {
const headerContainer = document.createElement('div');
headerContainer.id = 'header-container';
existingHeader.parentNode?.replaceChild(headerContainer, existingHeader);
header.render();
}
}
// Admin page doesn't have a footer in the current design
// but we could add one if needed
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeAdminApp);
} else {
initializeAdminApp();
}
// Export for use in other scripts if needed
(window as any).SharedHeader = SharedHeader;
(window as any).SharedFooter = SharedFooter;

31
src/frontend/app-main.ts Normal file
View file

@ -0,0 +1,31 @@
/**
* Main app entry point - uses shared components
*/
import { SharedHeader } from './components/SharedHeader';
import { SharedFooter } from './components/SharedFooter';
// Initialize shared components when DOM is ready
function initializeApp() {
// Render header
const header = SharedHeader.createMainHeader();
header.render();
// Render footer
const footer = SharedFooter.createStandardFooter();
footer.render();
// The rest of the app logic (map, form, etc.) remains in the existing app.js
// This just adds the shared components
}
// Wait for DOM and i18n to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeApp);
} else {
initializeApp();
}
// Export for use in other scripts if needed
(window as any).SharedHeader = SharedHeader;
(window as any).SharedFooter = SharedFooter;

View file

@ -0,0 +1,38 @@
/**
* Privacy page entry point - uses shared components
*/
import { SharedHeader } from './components/SharedHeader';
import { SharedFooter } from './components/SharedFooter';
// Initialize shared components when DOM is ready
function initializePrivacyApp() {
// For privacy page, we need to replace the existing header structure
const privacyHeader = document.querySelector('.privacy-header');
if (privacyHeader) {
const headerContainer = document.createElement('div');
headerContainer.id = 'header-container';
privacyHeader.parentNode?.replaceChild(headerContainer, privacyHeader);
const header = SharedHeader.createPrivacyHeader();
header.render();
}
// Add footer if there's a container for it
const footerContainer = document.getElementById('footer-container');
if (footerContainer) {
const footer = SharedFooter.createStandardFooter();
footer.render();
}
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePrivacyApp);
} else {
initializePrivacyApp();
}
// Export for use in other scripts if needed
(window as any).SharedHeader = SharedHeader;
(window as any).SharedFooter = SharedFooter;

View file

@ -0,0 +1,77 @@
/**
* Shared footer component for consistent footers across pages
*/
export interface SharedFooterConfig {
containerId?: string;
showSafetyNotice?: boolean;
showDisclaimer?: boolean;
}
export class SharedFooter {
private config: Required<SharedFooterConfig>;
constructor(config: SharedFooterConfig = {}) {
this.config = {
containerId: config.containerId || 'footer-container',
showSafetyNotice: config.showSafetyNotice !== false,
showDisclaimer: config.showDisclaimer !== false
};
}
public render(): void {
const container = document.getElementById(this.config.containerId);
if (!container) {
console.error(`Container with id "${this.config.containerId}" not found`);
return;
}
const footer = document.createElement('footer');
footer.innerHTML = `
${this.config.showSafetyNotice ? `
<p>
<span data-i18n="footer.safetyNotice">Safety Notice: This is a community tool for awareness. Stay safe and</span>
<a href="https://www.aclu.org/know-your-rights/immigrants-rights"
target="_blank"
rel="noopener noreferrer"
style="color: #007bff; text-decoration: underline;"
data-i18n="footer.knowRights">know your rights</a>.
</p>
` : ''}
${this.config.showDisclaimer ? `
<div class="disclaimer">
<small>
<span data-i18n="footer.disclaimer">This website is for informational purposes only. Verify information independently. Reports are automatically deleted after 48 hours. </span>
<a href="/privacy"
style="color: #007bff; text-decoration: underline;"
data-i18n="common.privacyPolicy">Privacy Policy</a>
</small>
</div>
` : ''}
`;
container.appendChild(footer);
// Update translations if i18n is available
if ((window as any).i18n?.updatePageTranslations) {
(window as any).i18n.updatePageTranslations();
}
}
// Factory method for standard footer
public static createStandardFooter(): SharedFooter {
return new SharedFooter({
showSafetyNotice: true,
showDisclaimer: true
});
}
// Factory method for minimal footer (e.g., admin pages)
public static createMinimalFooter(): SharedFooter {
return new SharedFooter({
showSafetyNotice: false,
showDisclaimer: true
});
}
}

View file

@ -0,0 +1,154 @@
/**
* Shared header component for consistent headers across pages
*/
export interface ButtonConfig {
id?: string;
href?: string;
class?: string;
icon?: string;
text?: string;
i18nKey?: string;
attributes?: Record<string, string>;
}
export interface SharedHeaderConfig {
showLanguageSelector?: boolean;
showThemeToggle?: boolean;
additionalButtons?: ButtonConfig[];
titleKey?: string;
subtitleKey?: string | null;
containerId?: string;
}
export class SharedHeader {
private config: Required<SharedHeaderConfig>;
constructor(config: SharedHeaderConfig = {}) {
this.config = {
showLanguageSelector: config.showLanguageSelector !== false,
showThemeToggle: config.showThemeToggle !== false,
additionalButtons: config.additionalButtons || [],
titleKey: config.titleKey || 'common.appName',
subtitleKey: config.subtitleKey || null,
containerId: config.containerId || 'header-container'
};
}
public render(): void {
const container = document.getElementById(this.config.containerId);
if (!container) {
console.error(`Container with id "${this.config.containerId}" not found`);
return;
}
const header = document.createElement('header');
header.innerHTML = `
<div class="header-content">
<div class="header-text">
<h1><a href="/" style="text-decoration: none; color: inherit;"></a> <span data-i18n="${this.config.titleKey}"></span></h1>
${this.config.subtitleKey ? `<p data-i18n="${this.config.subtitleKey}"></p>` : ''}
</div>
<div class="header-controls">
${this.config.showLanguageSelector ? '<div id="language-selector-container" class="language-selector-container"></div>' : ''}
${this.config.showThemeToggle ? this.renderThemeToggle() : ''}
${this.config.additionalButtons.map(btn => this.renderButton(btn)).join('')}
</div>
</div>
`;
container.appendChild(header);
// Initialize components after rendering
if (this.config.showThemeToggle) {
this.initializeThemeToggle();
}
// Update translations if i18n is available
if ((window as any).i18n?.updatePageTranslations) {
(window as any).i18n.updatePageTranslations();
}
}
private renderThemeToggle(): string {
return `
<button id="theme-toggle" class="theme-toggle js-only" title="Toggle dark mode" data-i18n-title="common.darkMode">
<span class="theme-icon">🌙</span>
</button>
`;
}
private renderButton(btn: ButtonConfig): string {
const attrs = btn.attributes
? Object.entries(btn.attributes).map(([k, v]) => `${k}="${v}"`).join(' ')
: '';
const i18nAttr = btn.i18nKey ? `data-i18n="${btn.i18nKey}"` : '';
const text = btn.text || '';
const icon = btn.icon || '';
const idAttr = btn.id ? `id="${btn.id}"` : '';
if (btn.href) {
return `<a href="${btn.href}" class="${btn.class || 'header-btn'}" ${attrs} ${i18nAttr}>${icon}${icon && text ? ' ' : ''}${text}</a>`;
} else {
return `<button ${idAttr} class="${btn.class || 'header-btn'}" ${attrs} ${i18nAttr}>${icon}${icon && text ? ' ' : ''}${text}</button>`;
}
}
private initializeThemeToggle(): void {
const themeToggle = document.getElementById('theme-toggle') as HTMLButtonElement;
if (!themeToggle) return;
const updateThemeIcon = (): void => {
const theme = document.documentElement.getAttribute('data-theme');
const icon = themeToggle.querySelector('.theme-icon');
if (icon) {
icon.textContent = theme === 'dark' ? '☀️' : '🌙';
}
};
themeToggle.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon();
});
// Set initial icon
updateThemeIcon();
}
// Factory methods for common headers
public static createMainHeader(): SharedHeader {
return new SharedHeader({
titleKey: 'common.appName',
subtitleKey: 'meta.subtitle',
showLanguageSelector: true,
showThemeToggle: true
});
}
public static createAdminHeader(): SharedHeader {
return new SharedHeader({
titleKey: 'admin.adminPanel',
showLanguageSelector: true,
showThemeToggle: true,
additionalButtons: [
{ href: '/', class: 'header-btn btn-home', icon: '🏠', text: 'Homepage', i18nKey: 'common.homepage' },
{ id: 'refresh-btn', class: 'header-btn btn-refresh', icon: '🔄', text: 'Refresh Data', i18nKey: 'common.refresh' },
{ id: 'logout-btn', class: 'header-btn btn-logout', icon: '🚪', text: 'Logout', i18nKey: 'common.logout' }
]
});
}
public static createPrivacyHeader(): SharedHeader {
return new SharedHeader({
titleKey: 'privacy.title',
subtitleKey: 'common.appName',
showLanguageSelector: false,
showThemeToggle: true
});
}
}

View file

@ -0,0 +1,23 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["leaflet"]
},
"include": [
"**/*.ts",
"**/*.tsx"
]
}