Compare commits

...

137 Commits

Author SHA1 Message Date
4ef4ac414b feat(campaigns): add highlighted campaign feature with admin controls and UI updates 2025-11-07 10:15:41 -07:00
1bdc2b9ae0 some updates 2025-11-06 23:26:42 -07:00
4d8b9effd0 feat(blog): add detailed update on Influence and Map app developments since August
A bunch of udpates to the listmonk sync to add influence to it
2025-10-25 12:45:35 -06:00
e5c32ad25a Add health check utility, logger, metrics, backup, and SMTP toggle scripts
- Implemented a comprehensive health check utility to monitor system dependencies including NocoDB, SMTP, Represent API, disk space, and memory usage.
- Created a logger utility using Winston for structured logging with daily rotation and various log levels.
- Developed a metrics utility using Prometheus client to track application performance metrics such as email sends, HTTP requests, and user activity.
- Added a backup script for automated backups of NocoDB data, uploaded files, and environment configurations with optional S3 support.
- Introduced a toggle script to switch between development (MailHog) and production (ProtonMail) SMTP configurations.
2025-10-23 11:33:00 -06:00
4b5e2249dd add verify button to the response wall and qr code generation 2025-10-17 11:30:26 -06:00
8372b8a4bd Visual updates to the response wall 2025-10-17 10:48:44 -06:00
91a3f62b93 Verfied response system for electeds 2025-10-16 12:12:54 -06:00
ffb09a01f8 geocoding fixes 2025-10-16 10:44:49 -06:00
4fb9847812 Password updator for users / admin 2025-10-15 10:51:08 -06:00
06ecffaf4d new system for creating campaigns from the main site dashboard 2025-10-15 10:32:18 -06:00
b71a6e4ff3 Updates to the html for url construction throughout and a bunch of upgrades to how the response wall works 2025-10-14 11:28:19 -06:00
9da13d6d3d Response wall build out 2025-10-11 22:56:48 -06:00
ccececaf25 New resposne wall coding started. 2025-10-10 22:11:20 -06:00
7cc6100e9b Fixed some database errors and included influence in the network for changemaker 2025-10-09 09:46:45 -06:00
607062d365 A tonne of updates; site info, documentation, campaign phone numbers, soccial share buttons, and several other things 2025-10-04 12:25:25 -06:00
8915299707 Updated campagin cover photos 2025-10-01 12:20:55 -06:00
ba246d5dc8 debugged some stuff 2025-09-30 16:20:50 -06:00
dfe244f821 Whole new user interface and user system 2025-09-30 15:47:57 -06:00
9aaefd149e New update for the geo-coding system to include system to automatically scan the nocodb locations to build geo-locations 2025-09-26 11:35:12 -06:00
44298834ef Large update to geo-coding functions in order to support better matching of street addresses. Added premium mapbox option 2025-09-25 11:28:51 -06:00
d29ffa6300 Bunch of buag fixes and updates 2025-09-24 12:37:26 -06:00
a26d9b8d78 Tonne of updates to influence, the configs, update the homepage, and generally just did more bug testing with Influence 2025-09-21 13:36:48 -06:00
cd1099c428 tonne of imporvements, debugs, new ui 2025-09-20 15:58:55 -06:00
f93765f38b Updated the campaign page to get the representaives working properly. 2025-09-20 12:53:31 -06:00
e037017817 Pushing the new influence app in its current state 2025-09-20 11:19:26 -06:00
83f5055471 some udpates to stuff, getting started on influence, map udpates for workflow ease of use, updates to map z-idexes for visibility 2025-09-18 20:21:50 -06:00
b61e48f4fc Updates to user management 2025-09-11 13:42:07 -06:00
006cbcf9c3 Fixed spatial issues with finding map cuts data. 2025-09-10 20:57:07 -06:00
ebf9ff23ab Cuts bug fixes and updates 2025-09-10 19:20:03 -06:00
f44cb35253 another build update 2025-09-10 18:07:09 -06:00
609f89ec0c updates to the build system 2025-09-10 17:35:48 -06:00
56b1600c37 Fixed some bugs with menus and updated the build-nocodb to migrate data. 2025-09-10 12:33:55 -06:00
e3611c8300 fixed a bug for manage volunteers 2025-09-08 16:14:29 -06:00
00a2117cb9 map cuts updates 2025-09-08 11:50:38 -06:00
bc08f0b55d Cut updates. Still need to test the assignment system. 2025-09-08 11:23:27 -06:00
459cea0c3b Fixed the print view and things seem to be working now. 2025-09-07 11:50:44 -06:00
b3cd1a3331 Semi working map cuts view; need to refactor and fix some stuff however stable enough to commit 2025-09-07 11:08:27 -06:00
59491ccdc6 Refactor of admin.js into readable files. Big refactor 2025-09-05 13:01:17 -06:00
0ed0c4b38d Updates to documentation, fixes for edit buttons in map shifts, and CORS for local dev and access 2025-09-05 12:23:46 -06:00
87767b07e2 update map convertor - untested 2025-09-02 21:31:25 -06:00
24ce74d61c some fixes to the auth and lockouts 2025-08-27 08:54:22 -06:00
a026af5b48 New public shifts system 2025-08-22 14:45:40 -06:00
960bd39e21 Bunch of updates for temp users and logging securely. 2025-08-19 12:09:19 -06:00
3b88eef397 fixed some of the loading bugs with shifts so that the map can load faster. 2025-08-18 14:03:36 -06:00
c2ccddd1dc shifsts bug 2025-08-17 09:03:28 -06:00
b7263188f9 debugged a endpoint 2025-08-16 14:25:52 -06:00
26717f89f7 refactored the admin.css 2025-08-16 00:04:05 -06:00
a96318f19e Updates to handle maps above 1000 locations 2025-08-15 17:56:06 -06:00
94600839f0 download data import csv report 2025-08-15 17:17:28 -06:00
2a30d3857c docker compose clean up 2025-08-15 12:39:18 -06:00
91a3084f06 updates to config 2025-08-15 11:44:33 -06:00
c973fe55cd listmonk sync 2025-08-15 11:14:38 -06:00
b885d89ae4 Fixed admin rate issues 2025-08-11 14:16:44 -06:00
d8c08c8451 shift updated fixes 2025-08-11 14:01:25 -06:00
b2641f9daa small fixes to build process for documentation search pull 2025-08-10 18:27:39 -06:00
8c03b321fc build nocod now inputs the new urls to the .env 2025-08-10 17:08:44 -06:00
35a6d55ffe A couple more fast email buttons 2025-08-08 17:47:26 -06:00
b5cf9b3f8d Some data work and also adding in some new config 2025-08-08 12:58:01 -06:00
0d3a273e22 fixed cut overlays and solved the docker duplication thing 2025-08-06 16:04:41 -06:00
f4327c3c40 Pushing Cuts to repo. Still bugs however decently stable. 2025-08-06 13:47:51 -06:00
423e561ea3 few more debugs for temp users 2025-08-04 13:04:53 -06:00
2591cfe8a8 updated temp user control access and limited data send for temps 2025-08-04 12:54:04 -06:00
2ebbb2dc44 Temp user updates and several bug fixes 2025-08-03 16:08:11 -06:00
2a53008e04 Standardized the z-indexs and did some pop up updates 2025-08-03 14:31:51 -06:00
3231a4973c some quick updates to get cconvert up and working again 2025-08-03 13:50:07 -06:00
532286217e Updates to sending user details 2025-08-03 13:26:20 -06:00
e488a23bfb few small changes to language on the admin page 2025-08-01 15:13:53 -06:00
5b673dacc2 A tonne more changes, including new nocodb admin section, search for database, code cleanups, and debugging 2025-08-01 15:01:35 -06:00
9fcaf4823f website updates 2025-08-01 11:51:29 -06:00
55cd626173 Data converter 2025-08-01 10:32:07 -06:00
0dacdfc1f0 udpated config to properly build for smtp 2025-07-31 12:10:36 -06:00
52d921c141 smtp integration and password recovery. 2025-07-31 11:58:48 -06:00
c0811de8fa a few more dashboard updates 2025-07-31 10:59:22 -06:00
d775dea0dc mobile friendliness 2025-07-30 09:15:56 -06:00
373018cebb anaylitics dashboard 2025-07-30 09:04:21 -06:00
7edc66565c square representations of apartments 2025-07-30 08:55:50 -06:00
0d7bdf01e2 Updated apartment locations to stand out mor 2025-07-29 11:07:21 -06:00
dfe7c6997c some auth updates that got over written 2025-07-28 11:10:16 -06:00
8cebb567b1 Fixed the input form modal to be above the other modals. 2025-07-27 17:53:10 -06:00
d711456b88 Full update to map css 2025-07-27 17:49:37 -06:00
994440a2fd Added apartment views and city of edmonton data import 2025-07-27 16:19:12 -06:00
5da24aed56 New site frontend updates, search, and general bug fixes 2025-07-27 12:43:07 -06:00
3b7d382ad8 Some udpates to tracking user inputs. Still not happy with it but functional so moving on 2025-07-24 17:09:34 -06:00
bb7032d649 A tonne of updates to how the system builds the view points in hopes of having a better mobile expereince 2025-07-24 12:42:27 -06:00
59ca2379f2 QR Code Maker update 2025-07-22 12:25:12 -06:00
dd416f8bdf fixed the search float 2025-07-20 10:25:46 -06:00
54b9210a18 new stuff 2025-07-19 16:38:32 -06:00
5bf87d4c3f New search functionality 2025-07-19 15:31:29 -06:00
0088ffd6bb few cosmetic updates 2025-07-19 14:28:00 -06:00
7989ea07c4 added a move system for the pins 2025-07-18 10:46:23 -06:00
b98207b118 Fixed some menu bugs 2025-07-17 19:40:31 -06:00
6aae0fee41 added in the user managment section. Need to also do some updates to the admin menue and whatnot however itll get figured. 2025-07-17 17:48:50 -06:00
88b80bc750 bug fixes for shifts functionality 2025-07-16 18:38:29 -06:00
65c786d3db new system for address confrimation 2025-07-16 18:25:50 -06:00
ff3e1e868b Added calendar view to the shifts 2025-07-16 18:05:49 -06:00
e0562904a8 updated the config.sh so that multiple changemakers can be run on each machine easier. 2025-07-16 17:51:55 -06:00
167b82ff35 added shift titles to the shift signup sheets to make tracking easier and adding shifts easier 2025-07-16 17:47:11 -06:00
fb90f2a58c Config fixes for the services. yaml and a couple minor things about making it more url agnostic. 2025-07-16 10:38:07 -06:00
2c0c943c3b config changes to better build map .env and updates to the login ui controls 2025-07-16 10:28:28 -06:00
0dd56c0c75 Couple more updates to shiftcontroller to make it more robust 2025-07-16 09:51:55 -06:00
a5bd0e9939 Fixed bug for displaying sign ups for shifts 2025-07-16 09:47:59 -06:00
9e5b3193f7 Fixed bug with where maps presents 2025-07-13 17:42:59 -06:00
2b05b608ba debugged build precision values on decimal places inside nocodb 2025-07-11 10:00:37 -06:00
08782379ab Fixes to the map display and several other bugs 2025-07-11 08:50:04 -06:00
c29ad2d3a9 Shifts manager 2025-07-10 16:07:17 -06:00
5cba67226e updates to saving settings 2025-07-10 10:56:52 -06:00
2778b1590d map system updates 2025-07-09 20:06:23 -06:00
1236c6b646 Mpas manual and udpates to map view buttons for simpler workflow 2025-07-09 17:18:34 -06:00
34ef38a949 UI updates to better position map for building dots 2025-07-09 15:57:09 -06:00
d11837e449 Added in a password field for login. need to add encryption sometime 2025-07-09 10:05:12 -06:00
ab2e91ec12 fixed the preview on moblie for the walk sheet 2025-07-08 23:21:51 -06:00
e31b77017c naming updates 2025-07-08 09:37:25 -06:00
de3b349e60 documentation updates 2025-07-08 09:18:14 -06:00
56ab4001de map styling update 2025-07-07 15:57:52 -06:00
488bb9995f build nocodb udpate - makes new base on every run 2025-07-07 10:13:00 -06:00
c1f6f25c7d okay bidirectional saving done 2025-07-06 22:50:07 -06:00
1ba3899669 final update for the map server omg 2025-07-06 22:22:33 -06:00
18de90f3bc got config save working 2025-07-06 21:01:27 -06:00
5f39ce8218 more configs and readme updates. 2025-07-06 16:07:55 -06:00
c4ea51995e reste.sh updatae 2025-07-06 15:57:59 -06:00
ac01d925ca okay got to a much more stable state. Fixed race condtion at stat of files. Should be smooth salining for a bit now. 2025-07-06 00:58:46 -06:00
1fc8b52840 final round of updates. Still need to stabalize first load for the map, having issues for sure; longer load time 2025-07-06 00:43:10 -06:00
f4eefa1cae resolved most in browser errors and got maps stable. Next need to do some work on the save configuration stuff. 2025-07-05 23:14:15 -06:00
412ca36192 config.sh fix 2025-07-05 21:22:26 -06:00
776420b6b8 build shell script for NocoDB map data 2025-07-05 19:52:17 -06:00
77c3a32230 new maps admin feature 2025-07-05 16:01:39 -06:00
09c8e02926 start production updates 2025-07-05 10:47:34 -06:00
4b6acbd6bb update so we can run more than one changemaker per machine 2025-07-04 22:56:24 -06:00
e9d5af3d24 removed the cloudflare credentials and yml from up 2025-07-04 22:50:24 -06:00
d41bd87c34 pushing updates before doing a reset on local machine. 2025-07-04 14:30:50 -06:00
ed0bd33ee9 Clean up 2025-07-04 14:30:22 -06:00
949be0bc6a maps updates 2025-07-03 20:03:04 -06:00
7e65665ad6 Some updates to the start-production script and just general clean up 2025-07-03 07:42:25 -06:00
ea6e5f17b2 a tonne of improvements! We got maps updaTed, a tonne of documentaiton done, and several small upgrades to hook logic and other things. Fixed the looping problem for mkdocs and claude is not integrated with coder; if people wanna have a ai anyway 2025-07-02 11:49:22 -06:00
9963c57b9b documentation for maps and stuff 2025-07-01 09:33:27 -06:00
4aa0c225e2 UPDATES 2025-06-30 21:38:14 -06:00
b5cabd7a72 Tonnes of updates to the site documentation and general processes 2025-06-30 15:49:39 -06:00
610 changed files with 158201 additions and 11111 deletions

View File

@ -0,0 +1,350 @@
# Details
Date : 2025-09-05 12:42:08
Directory /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite
Total : 335 files, 91924 codes, 4969 comments, 47542 blanks, all 144435 lines
[Summary](results.md) / Details / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
## Files
| filename | language | code | comment | blank | total |
| :--- | :--- | ---: | ---: | ---: | ---: |
| [README.md](/README.md) | Markdown | 55 | 0 | 24 | 79 |
| [combined.log](/combined.log) | Log | 13 | 0 | 3 | 16 |
| [config.sh](/config.sh) | Shell Script | 810 | 177 | 224 | 1,211 |
| [configs/homepage/bookmarks.yaml](/configs/homepage/bookmarks.yaml) | YAML | 47 | 2 | 5 | 54 |
| [configs/homepage/custom.css](/configs/homepage/custom.css) | PostCSS | 0 | 0 | 1 | 1 |
| [configs/homepage/custom.js](/configs/homepage/custom.js) | JavaScript | 0 | 0 | 1 | 1 |
| [configs/homepage/docker.yaml](/configs/homepage/docker.yaml) | YAML | 3 | 2 | 2 | 7 |
| [configs/homepage/kubernetes.yaml](/configs/homepage/kubernetes.yaml) | YAML | 1 | 1 | 1 | 3 |
| [configs/homepage/logs/homepage.log](/configs/homepage/logs/homepage.log) | Log | 692 | 0 | 14 | 706 |
| [configs/homepage/services.yaml](/configs/homepage/services.yaml) | YAML | 59 | 1 | 15 | 75 |
| [configs/homepage/settings.yaml](/configs/homepage/settings.yaml) | YAML | 36 | 4 | 10 | 50 |
| [configs/homepage/widgets.yaml](/configs/homepage/widgets.yaml) | YAML | 26 | 2 | 5 | 33 |
| [configs/mkdocs-site/default.conf](/configs/mkdocs-site/default.conf) | Properties | 33 | 7 | 9 | 49 |
| [docker-compose.yml](/docker-compose.yml) | YAML | 238 | 2 | 13 | 253 |
| [map/Instuctions.md](/map/Instuctions.md) | Markdown | 56 | 0 | 21 | 77 |
| [map/README.md](/map/README.md) | Markdown | 851 | 0 | 252 | 1,103 |
| [map/app/Dockerfile](/map/app/Dockerfile) | Docker | 20 | 7 | 10 | 37 |
| [map/app/config/index.js](/map/app/config/index.js) | JavaScript | 118 | 15 | 18 | 151 |
| [map/app/controllers/authController.js](/map/app/controllers/authController.js) | JavaScript | 172 | 13 | 25 | 210 |
| [map/app/controllers/cutsController.js](/map/app/controllers/cutsController.js) | JavaScript | 276 | 41 | 47 | 364 |
| [map/app/controllers/dashboardController.js](/map/app/controllers/dashboardController.js) | JavaScript | 70 | 9 | 15 | 94 |
| [map/app/controllers/dataConvertController.js](/map/app/controllers/dataConvertController.js) | JavaScript | 352 | 50 | 76 | 478 |
| [map/app/controllers/externalDataController.js](/map/app/controllers/externalDataController.js) | JavaScript | 101 | 11 | 21 | 133 |
| [map/app/controllers/listmonkController.js](/map/app/controllers/listmonkController.js) | JavaScript | 212 | 12 | 29 | 253 |
| [map/app/controllers/locationsController.js](/map/app/controllers/locationsController.js) | JavaScript | 333 | 23 | 58 | 414 |
| [map/app/controllers/passwordRecoveryController.js](/map/app/controllers/passwordRecoveryController.js) | JavaScript | 45 | 4 | 12 | 61 |
| [map/app/controllers/publicShiftsController.js](/map/app/controllers/publicShiftsController.js) | JavaScript | 212 | 21 | 38 | 271 |
| [map/app/controllers/settingsController.js](/map/app/controllers/settingsController.js) | JavaScript | 303 | 17 | 50 | 370 |
| [map/app/controllers/shiftsController.js](/map/app/controllers/shiftsController.js) | JavaScript | 643 | 57 | 123 | 823 |
| [map/app/controllers/usersController.js](/map/app/controllers/usersController.js) | JavaScript | 341 | 18 | 54 | 413 |
| [map/app/middleware/auth.js](/map/app/middleware/auth.js) | JavaScript | 170 | 13 | 25 | 208 |
| [map/app/middleware/rateLimiter.js](/map/app/middleware/rateLimiter.js) | JavaScript | 85 | 10 | 9 | 104 |
| [map/app/package-lock.json](/map/app/package-lock.json) | JSON | 2,203 | 0 | 1 | 2,204 |
| [map/app/package.json](/map/app/package.json) | JSON | 43 | 0 | 1 | 44 |
| [map/app/public/admin.html](/map/app/public/admin.html) | HTML | 1,080 | 46 | 135 | 1,261 |
| [map/app/public/css/REFACTORING\_SUMMARY.md](/map/app/public/css/REFACTORING_SUMMARY.md) | Markdown | 51 | 0 | 13 | 64 |
| [map/app/public/css/admin.css](/map/app/public/css/admin.css) | PostCSS | 12 | 4 | 3 | 19 |
| [map/app/public/css/admin/cuts-shifts.css](/map/app/public/css/admin/cuts-shifts.css) | PostCSS | 225 | 13 | 44 | 282 |
| [map/app/public/css/admin/data-convert.css](/map/app/public/css/admin/data-convert.css) | PostCSS | 176 | 7 | 33 | 216 |
| [map/app/public/css/admin/forms.css](/map/app/public/css/admin/forms.css) | PostCSS | 207 | 9 | 36 | 252 |
| [map/app/public/css/admin/layout.css](/map/app/public/css/admin/layout.css) | PostCSS | 202 | 7 | 36 | 245 |
| [map/app/public/css/admin/modals.css](/map/app/public/css/admin/modals.css) | PostCSS | 259 | 6 | 46 | 311 |
| [map/app/public/css/admin/nocodb-links.css](/map/app/public/css/admin/nocodb-links.css) | PostCSS | 109 | 4 | 23 | 136 |
| [map/app/public/css/admin/responsive.css](/map/app/public/css/admin/responsive.css) | PostCSS | 552 | 28 | 112 | 692 |
| [map/app/public/css/admin/status-messages.css](/map/app/public/css/admin/status-messages.css) | PostCSS | 154 | 9 | 27 | 190 |
| [map/app/public/css/admin/user-management.css](/map/app/public/css/admin/user-management.css) | PostCSS | 179 | 10 | 31 | 220 |
| [map/app/public/css/admin/variables.css](/map/app/public/css/admin/variables.css) | PostCSS | 48 | 9 | 10 | 67 |
| [map/app/public/css/admin/walk-sheet.css](/map/app/public/css/admin/walk-sheet.css) | PostCSS | 252 | 12 | 39 | 303 |
| [map/app/public/css/modules/apartment-marker.css](/map/app/public/css/modules/apartment-marker.css) | PostCSS | 35 | 2 | 5 | 42 |
| [map/app/public/css/modules/apartment-popup.css](/map/app/public/css/modules/apartment-popup.css) | PostCSS | 197 | 9 | 30 | 236 |
| [map/app/public/css/modules/base.css](/map/app/public/css/modules/base.css) | PostCSS | 68 | 22 | 12 | 102 |
| [map/app/public/css/modules/buttons.css](/map/app/public/css/modules/buttons.css) | PostCSS | 70 | 2 | 16 | 88 |
| [map/app/public/css/modules/cache-busting.css](/map/app/public/css/modules/cache-busting.css) | PostCSS | 99 | 2 | 13 | 114 |
| [map/app/public/css/modules/cuts.css](/map/app/public/css/modules/cuts.css) | PostCSS | 829 | 22 | 146 | 997 |
| [map/app/public/css/modules/dashboard.css](/map/app/public/css/modules/dashboard.css) | PostCSS | 129 | 3 | 29 | 161 |
| [map/app/public/css/modules/doc-search.css](/map/app/public/css/modules/doc-search.css) | PostCSS | 123 | 2 | 20 | 145 |
| [map/app/public/css/modules/forms.css](/map/app/public/css/modules/forms.css) | PostCSS | 105 | 2 | 18 | 125 |
| [map/app/public/css/modules/layout.css](/map/app/public/css/modules/layout.css) | PostCSS | 83 | 5 | 11 | 99 |
| [map/app/public/css/modules/leaflet-custom.css](/map/app/public/css/modules/leaflet-custom.css) | PostCSS | 147 | 20 | 32 | 199 |
| [map/app/public/css/modules/listmonk.css](/map/app/public/css/modules/listmonk.css) | PostCSS | 295 | 4 | 55 | 354 |
| [map/app/public/css/modules/map-controls.css](/map/app/public/css/modules/map-controls.css) | PostCSS | 281 | 13 | 45 | 339 |
| [map/app/public/css/modules/mobile-ui.css](/map/app/public/css/modules/mobile-ui.css) | PostCSS | 205 | 8 | 36 | 249 |
| [map/app/public/css/modules/modal.css](/map/app/public/css/modules/modal.css) | PostCSS | 73 | 1 | 10 | 84 |
| [map/app/public/css/modules/nocodb-dropdown.css](/map/app/public/css/modules/nocodb-dropdown.css) | PostCSS | 134 | 4 | 24 | 162 |
| [map/app/public/css/modules/notifications.css](/map/app/public/css/modules/notifications.css) | PostCSS | 105 | 5 | 16 | 126 |
| [map/app/public/css/modules/print.css](/map/app/public/css/modules/print.css) | PostCSS | 11 | 1 | 2 | 14 |
| [map/app/public/css/modules/qr-code.css](/map/app/public/css/modules/qr-code.css) | PostCSS | 59 | 3 | 13 | 75 |
| [map/app/public/css/modules/responsive.css](/map/app/public/css/modules/responsive.css) | PostCSS | 150 | 12 | 29 | 191 |
| [map/app/public/css/modules/start-location-marker.css](/map/app/public/css/modules/start-location-marker.css) | PostCSS | 65 | 3 | 8 | 76 |
| [map/app/public/css/modules/temp-user.css](/map/app/public/css/modules/temp-user.css) | PostCSS | 46 | 6 | 5 | 57 |
| [map/app/public/css/modules/unified-search.css](/map/app/public/css/modules/unified-search.css) | PostCSS | 580 | 30 | 99 | 709 |
| [map/app/public/css/public-shifts.css](/map/app/public/css/public-shifts.css) | PostCSS | 418 | 24 | 83 | 525 |
| [map/app/public/css/shifts.css](/map/app/public/css/shifts.css) | PostCSS | 608 | 21 | 118 | 747 |
| [map/app/public/css/style.css](/map/app/public/css/style.css) | PostCSS | 21 | 0 | 1 | 22 |
| [map/app/public/css/user.css](/map/app/public/css/user.css) | PostCSS | 364 | 13 | 70 | 447 |
| [map/app/public/index.html](/map/app/public/index.html) | HTML | 384 | 28 | 45 | 457 |
| [map/app/public/js/admin-auth.js](/map/app/public/js/admin-auth.js) | JavaScript | 63 | 11 | 14 | 88 |
| [map/app/public/js/admin-core.js](/map/app/public/js/admin-core.js) | JavaScript | 239 | 46 | 40 | 325 |
| [map/app/public/js/admin-cuts.js](/map/app/public/js/admin-cuts.js) | JavaScript | 1,505 | 184 | 302 | 1,991 |
| [map/app/public/js/admin-email.js](/map/app/public/js/admin-email.js) | JavaScript | 319 | 29 | 47 | 395 |
| [map/app/public/js/admin-map.js](/map/app/public/js/admin-map.js) | JavaScript | 178 | 26 | 46 | 250 |
| [map/app/public/js/admin-shift-volunteers.js](/map/app/public/js/admin-shift-volunteers.js) | JavaScript | 416 | 49 | 66 | 531 |
| [map/app/public/js/admin-shifts.js](/map/app/public/js/admin-shifts.js) | JavaScript | 330 | 35 | 55 | 420 |
| [map/app/public/js/admin-users.js](/map/app/public/js/admin-users.js) | JavaScript | 305 | 16 | 45 | 366 |
| [map/app/public/js/admin-walksheet.js](/map/app/public/js/admin-walksheet.js) | JavaScript | 387 | 35 | 50 | 472 |
| [map/app/public/js/admin.js](/map/app/public/js/admin.js) | JavaScript | 2,309 | 178 | 288 | 2,775 |
| [map/app/public/js/admin/auth.js](/map/app/public/js/admin/auth.js) | JavaScript | 0 | 0 | 1 | 1 |
| [map/app/public/js/admin/navigation.js](/map/app/public/js/admin/navigation.js) | JavaScript | 0 | 0 | 1 | 1 |
| [map/app/public/js/admin/shifts.js](/map/app/public/js/admin/shifts.js) | JavaScript | 0 | 0 | 1 | 1 |
| [map/app/public/js/admin/startLocation.js](/map/app/public/js/admin/startLocation.js) | JavaScript | 0 | 0 | 1 | 1 |
| [map/app/public/js/admin/users.js](/map/app/public/js/admin/users.js) | JavaScript | 0 | 0 | 1 | 1 |
| [map/app/public/js/admin/utils.js](/map/app/public/js/admin/utils.js) | JavaScript | 0 | 0 | 1 | 1 |
| [map/app/public/js/admin/walkSheet.js](/map/app/public/js/admin/walkSheet.js) | JavaScript | 0 | 0 | 1 | 1 |
| [map/app/public/js/auth.js](/map/app/public/js/auth.js) | JavaScript | 181 | 28 | 34 | 243 |
| [map/app/public/js/cache-manager.js](/map/app/public/js/cache-manager.js) | JavaScript | 219 | 71 | 28 | 318 |
| [map/app/public/js/config.js](/map/app/public/js/config.js) | JavaScript | 32 | 3 | 3 | 38 |
| [map/app/public/js/cut-controls.js](/map/app/public/js/cut-controls.js) | JavaScript | 982 | 163 | 145 | 1,290 |
| [map/app/public/js/cut-drawing.js](/map/app/public/js/cut-drawing.js) | JavaScript | 206 | 72 | 59 | 337 |
| [map/app/public/js/cut-manager.js](/map/app/public/js/cut-manager.js) | JavaScript | 359 | 96 | 79 | 534 |
| [map/app/public/js/dashboard.js](/map/app/public/js/dashboard.js) | JavaScript | 333 | 23 | 33 | 389 |
| [map/app/public/js/data-convert.js](/map/app/public/js/data-convert.js) | JavaScript | 510 | 64 | 118 | 692 |
| [map/app/public/js/database-search.js](/map/app/public/js/database-search.js) | JavaScript | 186 | 76 | 51 | 313 |
| [map/app/public/js/external-layers.js](/map/app/public/js/external-layers.js) | JavaScript | 513 | 39 | 45 | 597 |
| [map/app/public/js/listmonk-admin.js](/map/app/public/js/listmonk-admin.js) | JavaScript | 410 | 76 | 85 | 571 |
| [map/app/public/js/listmonk-status.js](/map/app/public/js/listmonk-status.js) | JavaScript | 169 | 25 | 32 | 226 |
| [map/app/public/js/location-manager.js](/map/app/public/js/location-manager.js) | JavaScript | 998 | 56 | 91 | 1,145 |
| [map/app/public/js/main.js](/map/app/public/js/main.js) | JavaScript | 178 | 40 | 36 | 254 |
| [map/app/public/js/map-manager.js](/map/app/public/js/map-manager.js) | JavaScript | 82 | 12 | 19 | 113 |
| [map/app/public/js/map-search.js](/map/app/public/js/map-search.js) | JavaScript | 120 | 44 | 36 | 200 |
| [map/app/public/js/mkdocs-search.js](/map/app/public/js/mkdocs-search.js) | JavaScript | 263 | 36 | 52 | 351 |
| [map/app/public/js/public-shifts.js](/map/app/public/js/public-shifts.js) | JavaScript | 518 | 15 | 25 | 558 |
| [map/app/public/js/search-manager.js](/map/app/public/js/search-manager.js) | JavaScript | 407 | 138 | 100 | 645 |
| [map/app/public/js/shifts.js](/map/app/public/js/shifts.js) | JavaScript | 1,059 | 35 | 55 | 1,149 |
| [map/app/public/js/ui-controls.js](/map/app/public/js/ui-controls.js) | JavaScript | 572 | 74 | 101 | 747 |
| [map/app/public/js/user.js](/map/app/public/js/user.js) | JavaScript | 81 | 12 | 19 | 112 |
| [map/app/public/js/utils.js](/map/app/public/js/utils.js) | JavaScript | 96 | 21 | 25 | 142 |
| [map/app/public/login.html](/map/app/public/login.html) | HTML | 459 | 3 | 76 | 538 |
| [map/app/public/public-shifts.html](/map/app/public/public-shifts.html) | HTML | 88 | 4 | 7 | 99 |
| [map/app/public/shifts.html](/map/app/public/shifts.html) | HTML | 73 | 5 | 8 | 86 |
| [map/app/public/user.html](/map/app/public/user.html) | HTML | 47 | 7 | 9 | 63 |
| [map/app/routes/admin.js](/map/app/routes/admin.js) | JavaScript | 80 | 15 | 16 | 111 |
| [map/app/routes/auth.js](/map/app/routes/auth.js) | JavaScript | 14 | 5 | 6 | 25 |
| [map/app/routes/cuts.js](/map/app/routes/cuts.js) | JavaScript | 18 | 6 | 7 | 31 |
| [map/app/routes/dashboard.js](/map/app/routes/dashboard.js) | JavaScript | 5 | 0 | 3 | 8 |
| [map/app/routes/dataConvert.js](/map/app/routes/dataConvert.js) | JavaScript | 21 | 4 | 6 | 31 |
| [map/app/routes/debug.js](/map/app/routes/debug.js) | JavaScript | 236 | 10 | 36 | 282 |
| [map/app/routes/external.js](/map/app/routes/external.js) | JavaScript | 7 | 2 | 4 | 13 |
| [map/app/routes/geocoding.js](/map/app/routes/geocoding.js) | JavaScript | 120 | 24 | 31 | 175 |
| [map/app/routes/index.js](/map/app/routes/index.js) | JavaScript | 153 | 29 | 36 | 218 |
| [map/app/routes/listmonk.js](/map/app/routes/listmonk.js) | JavaScript | 12 | 5 | 9 | 26 |
| [map/app/routes/locations.js](/map/app/routes/locations.js) | JavaScript | 11 | 5 | 6 | 22 |
| [map/app/routes/public.js](/map/app/routes/public.js) | JavaScript | 8 | 1 | 3 | 12 |
| [map/app/routes/publicShifts.js](/map/app/routes/publicShifts.js) | JavaScript | 8 | 1 | 2 | 11 |
| [map/app/routes/qr.js](/map/app/routes/qr.js) | JavaScript | 39 | 1 | 8 | 48 |
| [map/app/routes/settings.js](/map/app/routes/settings.js) | JavaScript | 8 | 2 | 3 | 13 |
| [map/app/routes/shifts.js](/map/app/routes/shifts.js) | JavaScript | 16 | 4 | 5 | 25 |
| [map/app/routes/users.js](/map/app/routes/users.js) | JavaScript | 11 | 6 | 7 | 24 |
| [map/app/server.js](/map/app/server.js) | JavaScript | 249 | 43 | 49 | 341 |
| [map/app/services/accountExpiration.js](/map/app/services/accountExpiration.js) | JavaScript | 59 | 11 | 19 | 89 |
| [map/app/services/email.js](/map/app/services/email.js) | JavaScript | 109 | 4 | 19 | 132 |
| [map/app/services/emailTemplates.js](/map/app/services/emailTemplates.js) | JavaScript | 59 | 10 | 16 | 85 |
| [map/app/services/geocoding.js](/map/app/services/geocoding.js) | JavaScript | 219 | 51 | 44 | 314 |
| [map/app/services/listmonk.js](/map/app/services/listmonk.js) | JavaScript | 412 | 36 | 66 | 514 |
| [map/app/services/nocodb.js](/map/app/services/nocodb.js) | JavaScript | 172 | 22 | 36 | 230 |
| [map/app/services/qrcode.js](/map/app/services/qrcode.js) | JavaScript | 110 | 33 | 20 | 163 |
| [map/app/services/socrata.js](/map/app/services/socrata.js) | JavaScript | 49 | 9 | 10 | 68 |
| [map/app/templates/email/login-details.html](/map/app/templates/email/login-details.html) | HTML | 113 | 0 | 4 | 117 |
| [map/app/templates/email/password-recovery.html](/map/app/templates/email/password-recovery.html) | HTML | 79 | 0 | 1 | 80 |
| [map/app/templates/email/public-shift-signup-existing.html](/map/app/templates/email/public-shift-signup-existing.html) | HTML | 161 | 0 | 22 | 183 |
| [map/app/templates/email/public-shift-signup-new.html](/map/app/templates/email/public-shift-signup-new.html) | HTML | 31 | 0 | 5 | 36 |
| [map/app/templates/email/shift-details.html](/map/app/templates/email/shift-details.html) | HTML | 152 | 0 | 5 | 157 |
| [map/app/templates/email/user-broadcast.html](/map/app/templates/email/user-broadcast.html) | HTML | 108 | 0 | 2 | 110 |
| [map/app/utils/cacheBusting.js](/map/app/utils/cacheBusting.js) | JavaScript | 105 | 51 | 24 | 180 |
| [map/app/utils/helpers.js](/map/app/utils/helpers.js) | JavaScript | 149 | 25 | 28 | 202 |
| [map/app/utils/logger.js](/map/app/utils/logger.js) | JavaScript | 38 | 2 | 3 | 43 |
| [map/build-nocodb.sh](/map/build-nocodb.sh) | Shell Script | 925 | 60 | 93 | 1,078 |
| [map/data/City of Edmonton - Neighbourhoods (Centroid Point)\_20250807.geojson](/map/data/City%20of%20Edmonton%20-%20Neighbourhoods%20(Centroid%20Point)_20250807.geojson) | JSON | 3 | 0 | 1 | 4 |
| [map/data/City of Edmonton - Neighbourhoods\_20250807.geojson](/map/data/City%20of%20Edmonton%20-%20Neighbourhoods_20250807.geojson) | JSON | 5 | 0 | 0 | 5 |
| [map/data/City of Edmonton Ward Boundary and Council Composition\_Current\_20250807.geojson](/map/data/City%20of%20Edmonton%20Ward%20Boundary%20and%20Council%20Composition_Current_20250807.geojson) | JSON | 5 | 0 | 0 | 5 |
| [map/data/City\_of\_Edmonton\_-\_Neighbourhoods\_20250807.csv](/map/data/City_of_Edmonton_-_Neighbourhoods_20250807.csv) | CSV | 689 | 0 | 1 | 690 |
| [map/data/City\_of\_Edmonton\_-\_Neighbourhoods\_\_Centroid\_Point\_\_20250807.csv](/map/data/City_of_Edmonton_-_Neighbourhoods__Centroid_Point__20250807.csv) | CSV | 407 | 0 | 1 | 408 |
| [map/data/City\_of\_Edmonton\_Ward\_Boundary\_and\_Council\_Composition\_Current\_20250807.csv](/map/data/City_of_Edmonton_Ward_Boundary_and_Council_Composition_Current_20250807.csv) | CSV | 13 | 0 | 1 | 14 |
| [map/data/convert\_edmonton\_optimized.js](/map/data/convert_edmonton_optimized.js) | JavaScript | 185 | 21 | 43 | 249 |
| [map/data/convert\_ward\_boundaries\_optimized.js](/map/data/convert_ward_boundaries_optimized.js) | JavaScript | 173 | 24 | 45 | 242 |
| [map/data/edmonton\_neighborhoods\_nocodb\_optimized.csv](/map/data/edmonton_neighborhoods_nocodb_optimized.csv) | CSV | 689 | 0 | 0 | 689 |
| [map/data/edmonton\_nocodb\_optimized.csv](/map/data/edmonton_nocodb_optimized.csv) | CSV | 1,095 | 0 | 406 | 1,501 |
| [map/data/edmonton\_ward\_boundaries\_optimized.csv](/map/data/edmonton_ward_boundaries_optimized.csv) | CSV | 13 | 0 | 0 | 13 |
| [map/data/exampledata.csv](/map/data/exampledata.csv) | CSV | 7 | 0 | 0 | 7 |
| [map/docker-compose.yml](/map/docker-compose.yml) | YAML | 31 | 0 | 3 | 34 |
| [map/files-explainer.md](/map/files-explainer.md) | Markdown | 292 | 0 | 293 | 585 |
| [map/instruct/ADMIN\_IMPLEMENTATION.md](/map/instruct/ADMIN_IMPLEMENTATION.md) | Markdown | 102 | 0 | 28 | 130 |
| [map/instruct/CUT\_IMPLEMENTATION\_SUMMARY.md](/map/instruct/CUT_IMPLEMENTATION_SUMMARY.md) | Markdown | 156 | 0 | 34 | 190 |
| [map/instruct/CUT\_PUBLIC\_IMPLEMENTATION.md](/map/instruct/CUT_PUBLIC_IMPLEMENTATION.md) | Markdown | 124 | 5 | 31 | 160 |
| [map/instruct/CUT\_SIMPLIFICATION\_SUMMARY.md](/map/instruct/CUT_SIMPLIFICATION_SUMMARY.md) | Markdown | 65 | 0 | 21 | 86 |
| [map/instruct/LISTMONK\_INTEGRATION\_GUIDE.md](/map/instruct/LISTMONK_INTEGRATION_GUIDE.md) | Markdown | 249 | 0 | 70 | 319 |
| [map/instruct/SHIFT\_PERFORMANCE\_FIX.md](/map/instruct/SHIFT_PERFORMANCE_FIX.md) | Markdown | 71 | 0 | 22 | 93 |
| [map/instruct/TEMP\_USER\_IMPLEMENTATION.md](/map/instruct/TEMP_USER_IMPLEMENTATION.md) | Markdown | 88 | 0 | 28 | 116 |
| [map/instruct/TEMP\_USER\_TEST.md](/map/instruct/TEMP_USER_TEST.md) | Markdown | 113 | 0 | 34 | 147 |
| [map/instruct/build-nocodb.md](/map/instruct/build-nocodb.md) | Markdown | 374 | 0 | 67 | 441 |
| [map/package-lock.json](/map/package-lock.json) | JSON | 17 | 0 | 1 | 18 |
| [map/package.json](/map/package.json) | JSON | 5 | 0 | 1 | 6 |
| [mkdocs/docs/adv/ansible.md](/mkdocs/docs/adv/ansible.md) | Markdown | 373 | 0 | 152 | 525 |
| [mkdocs/docs/adv/index.md](/mkdocs/docs/adv/index.md) | Markdown | 2 | 0 | 1 | 3 |
| [mkdocs/docs/adv/vscode-ssh.md](/mkdocs/docs/adv/vscode-ssh.md) | Markdown | 511 | 0 | 174 | 685 |
| [mkdocs/docs/assets/repo-data/admin-changemaker.lite.json](/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/docs/assets/repo-data/anthropics-claude-code.json](/mkdocs/docs/assets/repo-data/anthropics-claude-code.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/docs/assets/repo-data/coder-code-server.json](/mkdocs/docs/assets/repo-data/coder-code-server.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/docs/assets/repo-data/gethomepage-homepage.json](/mkdocs/docs/assets/repo-data/gethomepage-homepage.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/docs/assets/repo-data/go-gitea-gitea.json](/mkdocs/docs/assets/repo-data/go-gitea-gitea.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/docs/assets/repo-data/knadh-listmonk.json](/mkdocs/docs/assets/repo-data/knadh-listmonk.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/docs/assets/repo-data/lyqht-mini-qr.json](/mkdocs/docs/assets/repo-data/lyqht-mini-qr.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/docs/assets/repo-data/n8n-io-n8n.json](/mkdocs/docs/assets/repo-data/n8n-io-n8n.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/docs/assets/repo-data/nocodb-nocodb.json](/mkdocs/docs/assets/repo-data/nocodb-nocodb.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/docs/assets/repo-data/ollama-ollama.json](/mkdocs/docs/assets/repo-data/ollama-ollama.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/docs/assets/repo-data/squidfunk-mkdocs-material.json](/mkdocs/docs/assets/repo-data/squidfunk-mkdocs-material.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/docs/blog/index.md](/mkdocs/docs/blog/index.md) | Markdown | 0 | 0 | 1 | 1 |
| [mkdocs/docs/blog/posts/1.md](/mkdocs/docs/blog/posts/1.md) | Markdown | 6 | 0 | 3 | 9 |
| [mkdocs/docs/blog/posts/2.md](/mkdocs/docs/blog/posts/2.md) | Markdown | 10 | 0 | 4 | 14 |
| [mkdocs/docs/blog/posts/3.md](/mkdocs/docs/blog/posts/3.md) | Markdown | 54 | 0 | 21 | 75 |
| [mkdocs/docs/build/index.md](/mkdocs/docs/build/index.md) | Markdown | 336 | 0 | 150 | 486 |
| [mkdocs/docs/build/map.md](/mkdocs/docs/build/map.md) | Markdown | 155 | 0 | 62 | 217 |
| [mkdocs/docs/build/server.md](/mkdocs/docs/build/server.md) | Markdown | 122 | 0 | 27 | 149 |
| [mkdocs/docs/build/site.md](/mkdocs/docs/build/site.md) | Markdown | 74 | 0 | 34 | 108 |
| [mkdocs/docs/config/cloudflare-config.md](/mkdocs/docs/config/cloudflare-config.md) | Markdown | 45 | 0 | 16 | 61 |
| [mkdocs/docs/config/coder.md](/mkdocs/docs/config/coder.md) | Markdown | 139 | 0 | 77 | 216 |
| [mkdocs/docs/config/index.md](/mkdocs/docs/config/index.md) | Markdown | 3 | 0 | 4 | 7 |
| [mkdocs/docs/config/map.md](/mkdocs/docs/config/map.md) | Markdown | 299 | 0 | 91 | 390 |
| [mkdocs/docs/config/mkdocs.md](/mkdocs/docs/config/mkdocs.md) | Markdown | 102 | 0 | 42 | 144 |
| [mkdocs/docs/hooks/repo\_widget\_hook.py](/mkdocs/docs/hooks/repo_widget_hook.py) | Python | 119 | 24 | 16 | 159 |
| [mkdocs/docs/how to/canvass.md](/mkdocs/docs/how%20to/canvass.md) | Markdown | 2 | 0 | 3 | 5 |
| [mkdocs/docs/index.md](/mkdocs/docs/index.md) | Markdown | 85 | 0 | 29 | 114 |
| [mkdocs/docs/javascripts/gitea-widget.js](/mkdocs/docs/javascripts/gitea-widget.js) | JavaScript | 121 | 3 | 8 | 132 |
| [mkdocs/docs/javascripts/github-widget.js](/mkdocs/docs/javascripts/github-widget.js) | JavaScript | 144 | 10 | 14 | 168 |
| [mkdocs/docs/javascripts/home.js](/mkdocs/docs/javascripts/home.js) | JavaScript | 271 | 30 | 37 | 338 |
| [mkdocs/docs/manual/index.md](/mkdocs/docs/manual/index.md) | Markdown | 2 | 0 | 1 | 3 |
| [mkdocs/docs/manual/map.md](/mkdocs/docs/manual/map.md) | Markdown | 91 | 0 | 48 | 139 |
| [mkdocs/docs/overrides/lander.html](/mkdocs/docs/overrides/lander.html) | HTML | 1,872 | 11 | 259 | 2,142 |
| [mkdocs/docs/overrides/main.html](/mkdocs/docs/overrides/main.html) | HTML | 8 | 1 | 3 | 12 |
| [mkdocs/docs/phil/cost-comparison.md](/mkdocs/docs/phil/cost-comparison.md) | Markdown | 153 | 0 | 51 | 204 |
| [mkdocs/docs/phil/index.md](/mkdocs/docs/phil/index.md) | Markdown | 95 | 0 | 69 | 164 |
| [mkdocs/docs/services/code-server.md](/mkdocs/docs/services/code-server.md) | Markdown | 41 | 0 | 21 | 62 |
| [mkdocs/docs/services/gitea.md](/mkdocs/docs/services/gitea.md) | Markdown | 39 | 0 | 19 | 58 |
| [mkdocs/docs/services/homepage.md](/mkdocs/docs/services/homepage.md) | Markdown | 146 | 0 | 66 | 212 |
| [mkdocs/docs/services/index.md](/mkdocs/docs/services/index.md) | Markdown | 101 | 0 | 17 | 118 |
| [mkdocs/docs/services/listmonk.md](/mkdocs/docs/services/listmonk.md) | Markdown | 47 | 0 | 22 | 69 |
| [mkdocs/docs/services/map.md](/mkdocs/docs/services/map.md) | Markdown | 70 | 0 | 27 | 97 |
| [mkdocs/docs/services/mini-qr.md](/mkdocs/docs/services/mini-qr.md) | Markdown | 23 | 0 | 15 | 38 |
| [mkdocs/docs/services/mkdocs.md](/mkdocs/docs/services/mkdocs.md) | Markdown | 86 | 0 | 47 | 133 |
| [mkdocs/docs/services/n8n.md](/mkdocs/docs/services/n8n.md) | Markdown | 109 | 0 | 49 | 158 |
| [mkdocs/docs/services/nocodb.md](/mkdocs/docs/services/nocodb.md) | Markdown | 108 | 0 | 55 | 163 |
| [mkdocs/docs/services/postgresql.md](/mkdocs/docs/services/postgresql.md) | Markdown | 59 | 0 | 32 | 91 |
| [mkdocs/docs/services/static-server.md](/mkdocs/docs/services/static-server.md) | Markdown | 69 | 0 | 32 | 101 |
| [mkdocs/docs/stylesheets/extra.css](/mkdocs/docs/stylesheets/extra.css) | PostCSS | 498 | 18 | 82 | 598 |
| [mkdocs/docs/stylesheets/home.css](/mkdocs/docs/stylesheets/home.css) | PostCSS | 866 | 54 | 153 | 1,073 |
| [mkdocs/docs/test.md](/mkdocs/docs/test.md) | Markdown | 5 | 0 | 6 | 11 |
| [mkdocs/mkdocs.yml](/mkdocs/mkdocs.yml) | YAML | 167 | 11 | 11 | 189 |
| [mkdocs/site/404.html](/mkdocs/site/404.html) | HTML | 251 | 1 | 437 | 689 |
| [mkdocs/site/adv/ansible/index.html](/mkdocs/site/adv/ansible/index.html) | HTML | 1,653 | 1 | 1,326 | 2,980 |
| [mkdocs/site/adv/index.html](/mkdocs/site/adv/index.html) | HTML | 549 | 1 | 1,083 | 1,633 |
| [mkdocs/site/adv/vscode-ssh/index.html](/mkdocs/site/adv/vscode-ssh/index.html) | HTML | 1,926 | 1 | 1,358 | 3,285 |
| [mkdocs/site/assets/javascripts/bundle.50899def.min.js](/mkdocs/site/assets/javascripts/bundle.50899def.min.js) | JavaScript | 14 | 1 | 2 | 17 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.ar.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.ar.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.da.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.da.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.de.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.de.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.du.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.du.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.el.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.el.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.es.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.es.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.fi.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.fi.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.fr.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.fr.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.he.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.he.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.hi.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.hi.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.hu.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.hu.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.hy.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.hy.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.it.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.it.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.ja.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.ja.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.jp.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.jp.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.kn.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.kn.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.ko.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.ko.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.multi.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.multi.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.nl.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.nl.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.no.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.no.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.pt.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.pt.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.ro.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.ro.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.ru.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.ru.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.sa.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.sa.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.sv.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.sv.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.ta.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.ta.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.te.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.te.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.th.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.th.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.tr.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.tr.min.js) | JavaScript | 1 | 16 | 1 | 18 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.vi.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.vi.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/min/lunr.zh.min.js](/mkdocs/site/assets/javascripts/lunr/min/lunr.zh.min.js) | JavaScript | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/javascripts/lunr/tinyseg.js](/mkdocs/site/assets/javascripts/lunr/tinyseg.js) | JavaScript | 176 | 21 | 9 | 206 |
| [mkdocs/site/assets/javascripts/lunr/wordcut.js](/mkdocs/site/assets/javascripts/lunr/wordcut.js) | JavaScript | 4,882 | 915 | 911 | 6,708 |
| [mkdocs/site/assets/javascripts/workers/search.d50fe291.min.js](/mkdocs/site/assets/javascripts/workers/search.d50fe291.min.js) | JavaScript | 38 | 3 | 2 | 43 |
| [mkdocs/site/assets/repo-data/admin-changemaker.lite.json](/mkdocs/site/assets/repo-data/admin-changemaker.lite.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/site/assets/repo-data/anthropics-claude-code.json](/mkdocs/site/assets/repo-data/anthropics-claude-code.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/site/assets/repo-data/coder-code-server.json](/mkdocs/site/assets/repo-data/coder-code-server.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/site/assets/repo-data/gethomepage-homepage.json](/mkdocs/site/assets/repo-data/gethomepage-homepage.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/site/assets/repo-data/go-gitea-gitea.json](/mkdocs/site/assets/repo-data/go-gitea-gitea.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/site/assets/repo-data/knadh-listmonk.json](/mkdocs/site/assets/repo-data/knadh-listmonk.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/site/assets/repo-data/lyqht-mini-qr.json](/mkdocs/site/assets/repo-data/lyqht-mini-qr.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/site/assets/repo-data/n8n-io-n8n.json](/mkdocs/site/assets/repo-data/n8n-io-n8n.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/site/assets/repo-data/nocodb-nocodb.json](/mkdocs/site/assets/repo-data/nocodb-nocodb.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/site/assets/repo-data/ollama-ollama.json](/mkdocs/site/assets/repo-data/ollama-ollama.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/site/assets/repo-data/squidfunk-mkdocs-material.json](/mkdocs/site/assets/repo-data/squidfunk-mkdocs-material.json) | JSON | 16 | 0 | 0 | 16 |
| [mkdocs/site/assets/stylesheets/main.7e37652d.min.css](/mkdocs/site/assets/stylesheets/main.7e37652d.min.css) | PostCSS | 1 | 0 | 0 | 1 |
| [mkdocs/site/assets/stylesheets/palette.06af60db.min.css](/mkdocs/site/assets/stylesheets/palette.06af60db.min.css) | PostCSS | 1 | 0 | 0 | 1 |
| [mkdocs/site/blog/2025/07/03/blog-1/index.html](/mkdocs/site/blog/2025/07/03/blog-1/index.html) | HTML | 374 | 1 | 582 | 957 |
| [mkdocs/site/blog/2025/07/10/2/index.html](/mkdocs/site/blog/2025/07/10/2/index.html) | HTML | 392 | 1 | 583 | 976 |
| [mkdocs/site/blog/2025/08/01/3/index.html](/mkdocs/site/blog/2025/08/01/3/index.html) | HTML | 455 | 1 | 590 | 1,046 |
| [mkdocs/site/blog/archive/2025/index.html](/mkdocs/site/blog/archive/2025/index.html) | HTML | 442 | 1 | 582 | 1,025 |
| [mkdocs/site/blog/index.html](/mkdocs/site/blog/index.html) | HTML | 451 | 1 | 572 | 1,024 |
| [mkdocs/site/build/index.html](/mkdocs/site/build/index.html) | HTML | 1,312 | 1 | 1,199 | 2,512 |
| [mkdocs/site/build/map/index.html](/mkdocs/site/build/map/index.html) | HTML | 983 | 1 | 1,182 | 2,166 |
| [mkdocs/site/build/server/index.html](/mkdocs/site/build/server/index.html) | HTML | 1,045 | 1 | 1,234 | 2,280 |
| [mkdocs/site/build/site/index.html](/mkdocs/site/build/site/index.html) | HTML | 748 | 1 | 1,138 | 1,887 |
| [mkdocs/site/config/cloudflare-config/index.html](/mkdocs/site/config/cloudflare-config/index.html) | HTML | 740 | 1 | 1,146 | 1,887 |
| [mkdocs/site/config/coder/index.html](/mkdocs/site/config/coder/index.html) | HTML | 1,117 | 1 | 1,240 | 2,358 |
| [mkdocs/site/config/index.html](/mkdocs/site/config/index.html) | HTML | 550 | 1 | 1,083 | 1,634 |
| [mkdocs/site/config/map/index.html](/mkdocs/site/config/map/index.html) | HTML | 1,672 | 1 | 1,346 | 3,019 |
| [mkdocs/site/config/mkdocs/index.html](/mkdocs/site/config/mkdocs/index.html) | HTML | 833 | 1 | 1,151 | 1,985 |
| [mkdocs/site/hooks/repo\_widget\_hook.py](/mkdocs/site/hooks/repo_widget_hook.py) | Python | 119 | 24 | 16 | 159 |
| [mkdocs/site/how to/canvass/index.html](/mkdocs/site/how%20to/canvass/index.html) | HTML | 275 | 1 | 484 | 760 |
| [mkdocs/site/index.html](/mkdocs/site/index.html) | HTML | 1,872 | 11 | 259 | 2,142 |
| [mkdocs/site/javascripts/gitea-widget.js](/mkdocs/site/javascripts/gitea-widget.js) | JavaScript | 121 | 3 | 8 | 132 |
| [mkdocs/site/javascripts/github-widget.js](/mkdocs/site/javascripts/github-widget.js) | JavaScript | 144 | 10 | 14 | 168 |
| [mkdocs/site/javascripts/home.js](/mkdocs/site/javascripts/home.js) | JavaScript | 271 | 30 | 37 | 338 |
| [mkdocs/site/manual/index.html](/mkdocs/site/manual/index.html) | HTML | 549 | 1 | 1,083 | 1,633 |
| [mkdocs/site/manual/map/index.html](/mkdocs/site/manual/map/index.html) | HTML | 987 | 1 | 1,202 | 2,190 |
| [mkdocs/site/overrides/lander.html](/mkdocs/site/overrides/lander.html) | HTML | 1,872 | 11 | 259 | 2,142 |
| [mkdocs/site/overrides/main.html](/mkdocs/site/overrides/main.html) | HTML | 8 | 1 | 3 | 12 |
| [mkdocs/site/phil/cost-comparison/index.html](/mkdocs/site/phil/cost-comparison/index.html) | HTML | 1,300 | 1 | 766 | 2,067 |
| [mkdocs/site/phil/index.html](/mkdocs/site/phil/index.html) | HTML | 717 | 1 | 661 | 1,379 |
| [mkdocs/site/search/search\_index.json](/mkdocs/site/search/search_index.json) | JSON | 1 | 0 | 0 | 1 |
| [mkdocs/site/services/code-server/index.html](/mkdocs/site/services/code-server/index.html) | HTML | 755 | 1 | 1,155 | 1,911 |
| [mkdocs/site/services/gitea/index.html](/mkdocs/site/services/gitea/index.html) | HTML | 737 | 1 | 1,151 | 1,889 |
| [mkdocs/site/services/homepage/index.html](/mkdocs/site/services/homepage/index.html) | HTML | 1,119 | 1 | 1,235 | 2,355 |
| [mkdocs/site/services/index.html](/mkdocs/site/services/index.html) | HTML | 769 | 1 | 1,113 | 1,883 |
| [mkdocs/site/services/listmonk/index.html](/mkdocs/site/services/listmonk/index.html) | HTML | 775 | 1 | 1,159 | 1,935 |
| [mkdocs/site/services/map/index.html](/mkdocs/site/services/map/index.html) | HTML | 877 | 1 | 1,170 | 2,048 |
| [mkdocs/site/services/mini-qr/index.html](/mkdocs/site/services/mini-qr/index.html) | HTML | 707 | 1 | 1,147 | 1,855 |
| [mkdocs/site/services/mkdocs/index.html](/mkdocs/site/services/mkdocs/index.html) | HTML | 905 | 1 | 1,187 | 2,093 |
| [mkdocs/site/services/n8n/index.html](/mkdocs/site/services/n8n/index.html) | HTML | 1,057 | 1 | 1,223 | 2,281 |
| [mkdocs/site/services/nocodb/index.html](/mkdocs/site/services/nocodb/index.html) | HTML | 1,040 | 1 | 1,219 | 2,260 |
| [mkdocs/site/services/postgresql/index.html](/mkdocs/site/services/postgresql/index.html) | HTML | 880 | 1 | 1,190 | 2,071 |
| [mkdocs/site/services/static-server/index.html](/mkdocs/site/services/static-server/index.html) | HTML | 877 | 1 | 1,182 | 2,060 |
| [mkdocs/site/sitemap.xml](/mkdocs/site/sitemap.xml) | XML | 147 | 0 | 0 | 147 |
| [mkdocs/site/stylesheets/extra.css](/mkdocs/site/stylesheets/extra.css) | PostCSS | 498 | 18 | 82 | 598 |
| [mkdocs/site/stylesheets/home.css](/mkdocs/site/stylesheets/home.css) | PostCSS | 866 | 54 | 153 | 1,073 |
| [mkdocs/site/test/index.html](/mkdocs/site/test/index.html) | HTML | 278 | 1 | 484 | 763 |
| [reset-site.sh](/reset-site.sh) | Shell Script | 258 | 28 | 73 | 359 |
| [start-production.sh](/start-production.sh) | Shell Script | 487 | 77 | 98 | 662 |
| [test.md](/test.md) | Markdown | 1 | 0 | 0 | 1 |
[Summary](results.md) / Details / [Diff Summary](diff.md) / [Diff Details](diff-details.md)

View File

@ -0,0 +1,15 @@
# Diff Details
Date : 2025-09-05 12:42:08
Directory /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite
Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details
## Files
| filename | language | code | comment | blank | total |
| :--- | :--- | ---: | ---: | ---: | ---: |
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details

View File

@ -0,0 +1,2 @@
"filename", "language", "", "comment", "blank", "total"
"Total", "-", , 0, 0, 0
1 filename language comment blank total
2 Total - 0 0 0

View File

@ -0,0 +1,19 @@
# Diff Summary
Date : 2025-09-05 12:42:08
Directory /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite
Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)
## Languages
| language | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
## Directories
| path | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)

View File

@ -0,0 +1,22 @@
Date : 2025-09-05 12:42:08
Directory : /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite
Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
Languages
+----------+------------+------------+------------+------------+------------+
| language | files | code | comment | blank | total |
+----------+------------+------------+------------+------------+------------+
+----------+------------+------------+------------+------------+------------+
Directories
+------+------------+------------+------------+------------+------------+
| path | files | code | comment | blank | total |
+------+------------+------------+------------+------------+------------+
+------+------------+------------+------------+------------+------------+
Files
+----------+----------+------------+------------+------------+------------+
| filename | language | code | comment | blank | total |
+----------+----------+------------+------------+------------+------------+
| Total | | 0 | 0 | 0 | 0 |
+----------+----------+------------+------------+------------+------------+

View File

@ -0,0 +1,337 @@
"filename", "language", "Shell Script", "Markdown", "Log", "YAML", "JSON", "Properties", "JavaScript", "HTML", "PostCSS", "Python", "Docker", "CSV", "XML", "comment", "blank", "total"
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/README.md", "Markdown", 0, 55, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 24, 79
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/combined.log", "Log", 0, 0, 13, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/config.sh", "Shell Script", 810, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 177, 224, 1211
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/bookmarks.yaml", "YAML", 0, 0, 0, 47, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 5, 54
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/custom.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/custom.js", "JavaScript", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/docker.yaml", "YAML", 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 7
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/kubernetes.yaml", "YAML", 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 3
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/logs/homepage.log", "Log", 0, 0, 692, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 14, 706
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/services.yaml", "YAML", 0, 0, 0, 59, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 15, 75
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/settings.yaml", "YAML", 0, 0, 0, 36, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 10, 50
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/widgets.yaml", "YAML", 0, 0, 0, 26, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 5, 33
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/mkdocs-site/default.conf", "Properties", 0, 0, 0, 0, 0, 33, 0, 0, 0, 0, 0, 0, 0, 7, 9, 49
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/docker-compose.yml", "YAML", 0, 0, 0, 238, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 13, 253
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/Instuctions.md", "Markdown", 0, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 21, 77
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/README.md", "Markdown", 0, 851, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 252, 1103
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/Dockerfile", "Docker", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 20, 0, 0, 7, 10, 37
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/config/index.js", "JavaScript", 0, 0, 0, 0, 0, 0, 118, 0, 0, 0, 0, 0, 0, 15, 18, 151
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/authController.js", "JavaScript", 0, 0, 0, 0, 0, 0, 172, 0, 0, 0, 0, 0, 0, 13, 25, 210
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/cutsController.js", "JavaScript", 0, 0, 0, 0, 0, 0, 276, 0, 0, 0, 0, 0, 0, 41, 47, 364
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/dashboardController.js", "JavaScript", 0, 0, 0, 0, 0, 0, 70, 0, 0, 0, 0, 0, 0, 9, 15, 94
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/dataConvertController.js", "JavaScript", 0, 0, 0, 0, 0, 0, 352, 0, 0, 0, 0, 0, 0, 50, 76, 478
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/externalDataController.js", "JavaScript", 0, 0, 0, 0, 0, 0, 101, 0, 0, 0, 0, 0, 0, 11, 21, 133
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/listmonkController.js", "JavaScript", 0, 0, 0, 0, 0, 0, 212, 0, 0, 0, 0, 0, 0, 12, 29, 253
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/locationsController.js", "JavaScript", 0, 0, 0, 0, 0, 0, 333, 0, 0, 0, 0, 0, 0, 23, 58, 414
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/passwordRecoveryController.js", "JavaScript", 0, 0, 0, 0, 0, 0, 45, 0, 0, 0, 0, 0, 0, 4, 12, 61
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/publicShiftsController.js", "JavaScript", 0, 0, 0, 0, 0, 0, 212, 0, 0, 0, 0, 0, 0, 21, 38, 271
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/settingsController.js", "JavaScript", 0, 0, 0, 0, 0, 0, 303, 0, 0, 0, 0, 0, 0, 17, 50, 370
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/shiftsController.js", "JavaScript", 0, 0, 0, 0, 0, 0, 643, 0, 0, 0, 0, 0, 0, 57, 123, 823
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/usersController.js", "JavaScript", 0, 0, 0, 0, 0, 0, 341, 0, 0, 0, 0, 0, 0, 18, 54, 413
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/middleware/auth.js", "JavaScript", 0, 0, 0, 0, 0, 0, 170, 0, 0, 0, 0, 0, 0, 13, 25, 208
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/middleware/rateLimiter.js", "JavaScript", 0, 0, 0, 0, 0, 0, 85, 0, 0, 0, 0, 0, 0, 10, 9, 104
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/package-lock.json", "JSON", 0, 0, 0, 0, 2203, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2204
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/package.json", "JSON", 0, 0, 0, 0, 43, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 44
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/admin.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 1080, 0, 0, 0, 0, 0, 46, 135, 1261
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/REFACTORING_SUMMARY.md", "Markdown", 0, 51, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 64
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 0, 4, 3, 19
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/cuts-shifts.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 225, 0, 0, 0, 0, 13, 44, 282
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/data-convert.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 176, 0, 0, 0, 0, 7, 33, 216
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/forms.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 207, 0, 0, 0, 0, 9, 36, 252
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/layout.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 202, 0, 0, 0, 0, 7, 36, 245
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/modals.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 259, 0, 0, 0, 0, 6, 46, 311
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/nocodb-links.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 109, 0, 0, 0, 0, 4, 23, 136
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/responsive.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 552, 0, 0, 0, 0, 28, 112, 692
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/status-messages.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 154, 0, 0, 0, 0, 9, 27, 190
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/user-management.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 179, 0, 0, 0, 0, 10, 31, 220
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/variables.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 48, 0, 0, 0, 0, 9, 10, 67
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/walk-sheet.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 252, 0, 0, 0, 0, 12, 39, 303
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/apartment-marker.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 35, 0, 0, 0, 0, 2, 5, 42
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/apartment-popup.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 197, 0, 0, 0, 0, 9, 30, 236
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/base.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 68, 0, 0, 0, 0, 22, 12, 102
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/buttons.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 70, 0, 0, 0, 0, 2, 16, 88
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/cache-busting.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 99, 0, 0, 0, 0, 2, 13, 114
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/cuts.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 829, 0, 0, 0, 0, 22, 146, 997
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/dashboard.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 129, 0, 0, 0, 0, 3, 29, 161
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/doc-search.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 123, 0, 0, 0, 0, 2, 20, 145
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/forms.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 105, 0, 0, 0, 0, 2, 18, 125
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/layout.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 83, 0, 0, 0, 0, 5, 11, 99
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/leaflet-custom.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 147, 0, 0, 0, 0, 20, 32, 199
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/listmonk.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 295, 0, 0, 0, 0, 4, 55, 354
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/map-controls.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 281, 0, 0, 0, 0, 13, 45, 339
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/mobile-ui.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 205, 0, 0, 0, 0, 8, 36, 249
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/modal.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 73, 0, 0, 0, 0, 1, 10, 84
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/nocodb-dropdown.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 134, 0, 0, 0, 0, 4, 24, 162
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/notifications.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 105, 0, 0, 0, 0, 5, 16, 126
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/print.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 11, 0, 0, 0, 0, 1, 2, 14
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/qr-code.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 59, 0, 0, 0, 0, 3, 13, 75
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/responsive.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 150, 0, 0, 0, 0, 12, 29, 191
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/start-location-marker.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 65, 0, 0, 0, 0, 3, 8, 76
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/temp-user.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 46, 0, 0, 0, 0, 6, 5, 57
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/unified-search.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 580, 0, 0, 0, 0, 30, 99, 709
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/public-shifts.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 418, 0, 0, 0, 0, 24, 83, 525
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/shifts.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 608, 0, 0, 0, 0, 21, 118, 747
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/style.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 21, 0, 0, 0, 0, 0, 1, 22
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/user.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 364, 0, 0, 0, 0, 13, 70, 447
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 384, 0, 0, 0, 0, 0, 28, 45, 457
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-auth.js", "JavaScript", 0, 0, 0, 0, 0, 0, 63, 0, 0, 0, 0, 0, 0, 11, 14, 88
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-core.js", "JavaScript", 0, 0, 0, 0, 0, 0, 239, 0, 0, 0, 0, 0, 0, 46, 40, 325
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-cuts.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1505, 0, 0, 0, 0, 0, 0, 184, 302, 1991
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-email.js", "JavaScript", 0, 0, 0, 0, 0, 0, 319, 0, 0, 0, 0, 0, 0, 29, 47, 395
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-map.js", "JavaScript", 0, 0, 0, 0, 0, 0, 178, 0, 0, 0, 0, 0, 0, 26, 46, 250
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-shift-volunteers.js", "JavaScript", 0, 0, 0, 0, 0, 0, 416, 0, 0, 0, 0, 0, 0, 49, 66, 531
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-shifts.js", "JavaScript", 0, 0, 0, 0, 0, 0, 330, 0, 0, 0, 0, 0, 0, 35, 55, 420
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-users.js", "JavaScript", 0, 0, 0, 0, 0, 0, 305, 0, 0, 0, 0, 0, 0, 16, 45, 366
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-walksheet.js", "JavaScript", 0, 0, 0, 0, 0, 0, 387, 0, 0, 0, 0, 0, 0, 35, 50, 472
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin.js", "JavaScript", 0, 0, 0, 0, 0, 0, 2309, 0, 0, 0, 0, 0, 0, 178, 288, 2775
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/auth.js", "JavaScript", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/navigation.js", "JavaScript", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/shifts.js", "JavaScript", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/startLocation.js", "JavaScript", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/users.js", "JavaScript", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/utils.js", "JavaScript", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/walkSheet.js", "JavaScript", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/auth.js", "JavaScript", 0, 0, 0, 0, 0, 0, 181, 0, 0, 0, 0, 0, 0, 28, 34, 243
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/cache-manager.js", "JavaScript", 0, 0, 0, 0, 0, 0, 219, 0, 0, 0, 0, 0, 0, 71, 28, 318
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/config.js", "JavaScript", 0, 0, 0, 0, 0, 0, 32, 0, 0, 0, 0, 0, 0, 3, 3, 38
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/cut-controls.js", "JavaScript", 0, 0, 0, 0, 0, 0, 982, 0, 0, 0, 0, 0, 0, 163, 145, 1290
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/cut-drawing.js", "JavaScript", 0, 0, 0, 0, 0, 0, 206, 0, 0, 0, 0, 0, 0, 72, 59, 337
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/cut-manager.js", "JavaScript", 0, 0, 0, 0, 0, 0, 359, 0, 0, 0, 0, 0, 0, 96, 79, 534
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/dashboard.js", "JavaScript", 0, 0, 0, 0, 0, 0, 333, 0, 0, 0, 0, 0, 0, 23, 33, 389
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/data-convert.js", "JavaScript", 0, 0, 0, 0, 0, 0, 510, 0, 0, 0, 0, 0, 0, 64, 118, 692
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/database-search.js", "JavaScript", 0, 0, 0, 0, 0, 0, 186, 0, 0, 0, 0, 0, 0, 76, 51, 313
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/external-layers.js", "JavaScript", 0, 0, 0, 0, 0, 0, 513, 0, 0, 0, 0, 0, 0, 39, 45, 597
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/listmonk-admin.js", "JavaScript", 0, 0, 0, 0, 0, 0, 410, 0, 0, 0, 0, 0, 0, 76, 85, 571
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/listmonk-status.js", "JavaScript", 0, 0, 0, 0, 0, 0, 169, 0, 0, 0, 0, 0, 0, 25, 32, 226
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/location-manager.js", "JavaScript", 0, 0, 0, 0, 0, 0, 998, 0, 0, 0, 0, 0, 0, 56, 91, 1145
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/main.js", "JavaScript", 0, 0, 0, 0, 0, 0, 178, 0, 0, 0, 0, 0, 0, 40, 36, 254
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/map-manager.js", "JavaScript", 0, 0, 0, 0, 0, 0, 82, 0, 0, 0, 0, 0, 0, 12, 19, 113
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/map-search.js", "JavaScript", 0, 0, 0, 0, 0, 0, 120, 0, 0, 0, 0, 0, 0, 44, 36, 200
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/mkdocs-search.js", "JavaScript", 0, 0, 0, 0, 0, 0, 263, 0, 0, 0, 0, 0, 0, 36, 52, 351
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/public-shifts.js", "JavaScript", 0, 0, 0, 0, 0, 0, 518, 0, 0, 0, 0, 0, 0, 15, 25, 558
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/search-manager.js", "JavaScript", 0, 0, 0, 0, 0, 0, 407, 0, 0, 0, 0, 0, 0, 138, 100, 645
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/shifts.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1059, 0, 0, 0, 0, 0, 0, 35, 55, 1149
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/ui-controls.js", "JavaScript", 0, 0, 0, 0, 0, 0, 572, 0, 0, 0, 0, 0, 0, 74, 101, 747
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/user.js", "JavaScript", 0, 0, 0, 0, 0, 0, 81, 0, 0, 0, 0, 0, 0, 12, 19, 112
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/utils.js", "JavaScript", 0, 0, 0, 0, 0, 0, 96, 0, 0, 0, 0, 0, 0, 21, 25, 142
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/login.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 459, 0, 0, 0, 0, 0, 3, 76, 538
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/public-shifts.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 88, 0, 0, 0, 0, 0, 4, 7, 99
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/shifts.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 73, 0, 0, 0, 0, 0, 5, 8, 86
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/user.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 47, 0, 0, 0, 0, 0, 7, 9, 63
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/admin.js", "JavaScript", 0, 0, 0, 0, 0, 0, 80, 0, 0, 0, 0, 0, 0, 15, 16, 111
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/auth.js", "JavaScript", 0, 0, 0, 0, 0, 0, 14, 0, 0, 0, 0, 0, 0, 5, 6, 25
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/cuts.js", "JavaScript", 0, 0, 0, 0, 0, 0, 18, 0, 0, 0, 0, 0, 0, 6, 7, 31
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/dashboard.js", "JavaScript", 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 3, 8
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/dataConvert.js", "JavaScript", 0, 0, 0, 0, 0, 0, 21, 0, 0, 0, 0, 0, 0, 4, 6, 31
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/debug.js", "JavaScript", 0, 0, 0, 0, 0, 0, 236, 0, 0, 0, 0, 0, 0, 10, 36, 282
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/external.js", "JavaScript", 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 2, 4, 13
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/geocoding.js", "JavaScript", 0, 0, 0, 0, 0, 0, 120, 0, 0, 0, 0, 0, 0, 24, 31, 175
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/index.js", "JavaScript", 0, 0, 0, 0, 0, 0, 153, 0, 0, 0, 0, 0, 0, 29, 36, 218
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/listmonk.js", "JavaScript", 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 5, 9, 26
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/locations.js", "JavaScript", 0, 0, 0, 0, 0, 0, 11, 0, 0, 0, 0, 0, 0, 5, 6, 22
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/public.js", "JavaScript", 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 1, 3, 12
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/publicShifts.js", "JavaScript", 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 1, 2, 11
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/qr.js", "JavaScript", 0, 0, 0, 0, 0, 0, 39, 0, 0, 0, 0, 0, 0, 1, 8, 48
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/settings.js", "JavaScript", 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 2, 3, 13
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/shifts.js", "JavaScript", 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 4, 5, 25
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/users.js", "JavaScript", 0, 0, 0, 0, 0, 0, 11, 0, 0, 0, 0, 0, 0, 6, 7, 24
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/server.js", "JavaScript", 0, 0, 0, 0, 0, 0, 249, 0, 0, 0, 0, 0, 0, 43, 49, 341
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/accountExpiration.js", "JavaScript", 0, 0, 0, 0, 0, 0, 59, 0, 0, 0, 0, 0, 0, 11, 19, 89
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/email.js", "JavaScript", 0, 0, 0, 0, 0, 0, 109, 0, 0, 0, 0, 0, 0, 4, 19, 132
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/emailTemplates.js", "JavaScript", 0, 0, 0, 0, 0, 0, 59, 0, 0, 0, 0, 0, 0, 10, 16, 85
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/geocoding.js", "JavaScript", 0, 0, 0, 0, 0, 0, 219, 0, 0, 0, 0, 0, 0, 51, 44, 314
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/listmonk.js", "JavaScript", 0, 0, 0, 0, 0, 0, 412, 0, 0, 0, 0, 0, 0, 36, 66, 514
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/nocodb.js", "JavaScript", 0, 0, 0, 0, 0, 0, 172, 0, 0, 0, 0, 0, 0, 22, 36, 230
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/qrcode.js", "JavaScript", 0, 0, 0, 0, 0, 0, 110, 0, 0, 0, 0, 0, 0, 33, 20, 163
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/socrata.js", "JavaScript", 0, 0, 0, 0, 0, 0, 49, 0, 0, 0, 0, 0, 0, 9, 10, 68
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/login-details.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 113, 0, 0, 0, 0, 0, 0, 4, 117
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/password-recovery.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 79, 0, 0, 0, 0, 0, 0, 1, 80
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/public-shift-signup-existing.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 161, 0, 0, 0, 0, 0, 0, 22, 183
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/public-shift-signup-new.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 31, 0, 0, 0, 0, 0, 0, 5, 36
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/shift-details.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 152, 0, 0, 0, 0, 0, 0, 5, 157
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/user-broadcast.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 108, 0, 0, 0, 0, 0, 0, 2, 110
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/utils/cacheBusting.js", "JavaScript", 0, 0, 0, 0, 0, 0, 105, 0, 0, 0, 0, 0, 0, 51, 24, 180
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/utils/helpers.js", "JavaScript", 0, 0, 0, 0, 0, 0, 149, 0, 0, 0, 0, 0, 0, 25, 28, 202
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/utils/logger.js", "JavaScript", 0, 0, 0, 0, 0, 0, 38, 0, 0, 0, 0, 0, 0, 2, 3, 43
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/build-nocodb.sh", "Shell Script", 925, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 60, 93, 1078
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City of Edmonton - Neighbourhoods (Centroid Point)_20250807.geojson", "JSON", 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City of Edmonton - Neighbourhoods_20250807.geojson", "JSON", 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City of Edmonton Ward Boundary and Council Composition_Current_20250807.geojson", "JSON", 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City_of_Edmonton_-_Neighbourhoods_20250807.csv", "CSV", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 689, 0, 0, 1, 690
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City_of_Edmonton_-_Neighbourhoods__Centroid_Point__20250807.csv", "CSV", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 407, 0, 0, 1, 408
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City_of_Edmonton_Ward_Boundary_and_Council_Composition_Current_20250807.csv", "CSV", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 0, 0, 1, 14
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/convert_edmonton_optimized.js", "JavaScript", 0, 0, 0, 0, 0, 0, 185, 0, 0, 0, 0, 0, 0, 21, 43, 249
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/convert_ward_boundaries_optimized.js", "JavaScript", 0, 0, 0, 0, 0, 0, 173, 0, 0, 0, 0, 0, 0, 24, 45, 242
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/edmonton_neighborhoods_nocodb_optimized.csv", "CSV", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 689, 0, 0, 0, 689
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/edmonton_nocodb_optimized.csv", "CSV", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1095, 0, 0, 406, 1501
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/edmonton_ward_boundaries_optimized.csv", "CSV", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 0, 0, 0, 13
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/exampledata.csv", "CSV", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 7
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/docker-compose.yml", "YAML", 0, 0, 0, 31, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 34
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/files-explainer.md", "Markdown", 0, 292, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 293, 585
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/ADMIN_IMPLEMENTATION.md", "Markdown", 0, 102, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 28, 130
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/CUT_IMPLEMENTATION_SUMMARY.md", "Markdown", 0, 156, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 34, 190
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/CUT_PUBLIC_IMPLEMENTATION.md", "Markdown", 0, 124, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 31, 160
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/CUT_SIMPLIFICATION_SUMMARY.md", "Markdown", 0, 65, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 21, 86
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/LISTMONK_INTEGRATION_GUIDE.md", "Markdown", 0, 249, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 70, 319
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/SHIFT_PERFORMANCE_FIX.md", "Markdown", 0, 71, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 22, 93
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/TEMP_USER_IMPLEMENTATION.md", "Markdown", 0, 88, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 28, 116
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/TEMP_USER_TEST.md", "Markdown", 0, 113, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 34, 147
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/build-nocodb.md", "Markdown", 0, 374, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 67, 441
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/package-lock.json", "JSON", 0, 0, 0, 0, 17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/package.json", "JSON", 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 6
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/adv/ansible.md", "Markdown", 0, 373, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 152, 525
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/adv/index.md", "Markdown", 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 3
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/adv/vscode-ssh.md", "Markdown", 0, 511, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 174, 685
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/anthropics-claude-code.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/coder-code-server.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/gethomepage-homepage.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/go-gitea-gitea.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/knadh-listmonk.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/lyqht-mini-qr.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/n8n-io-n8n.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/nocodb-nocodb.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/ollama-ollama.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/squidfunk-mkdocs-material.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/blog/index.md", "Markdown", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/blog/posts/1.md", "Markdown", 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 9
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/blog/posts/2.md", "Markdown", 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 14
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/blog/posts/3.md", "Markdown", 0, 54, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 21, 75
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/build/index.md", "Markdown", 0, 336, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 150, 486
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/build/map.md", "Markdown", 0, 155, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 62, 217
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/build/server.md", "Markdown", 0, 122, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 27, 149
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/build/site.md", "Markdown", 0, 74, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 34, 108
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/cloudflare-config.md", "Markdown", 0, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 61
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/coder.md", "Markdown", 0, 139, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 77, 216
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/index.md", "Markdown", 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 7
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/map.md", "Markdown", 0, 299, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 91, 390
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/mkdocs.md", "Markdown", 0, 102, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 42, 144
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/hooks/repo_widget_hook.py", "Python", 0, 0, 0, 0, 0, 0, 0, 0, 0, 119, 0, 0, 0, 24, 16, 159
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/how to/canvass.md", "Markdown", 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 5
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/index.md", "Markdown", 0, 85, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 29, 114
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/javascripts/gitea-widget.js", "JavaScript", 0, 0, 0, 0, 0, 0, 121, 0, 0, 0, 0, 0, 0, 3, 8, 132
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/javascripts/github-widget.js", "JavaScript", 0, 0, 0, 0, 0, 0, 144, 0, 0, 0, 0, 0, 0, 10, 14, 168
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/javascripts/home.js", "JavaScript", 0, 0, 0, 0, 0, 0, 271, 0, 0, 0, 0, 0, 0, 30, 37, 338
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/manual/index.md", "Markdown", 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 3
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/manual/map.md", "Markdown", 0, 91, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 48, 139
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/overrides/lander.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 1872, 0, 0, 0, 0, 0, 11, 259, 2142
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/overrides/main.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 1, 3, 12
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/phil/cost-comparison.md", "Markdown", 0, 153, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 51, 204
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/phil/index.md", "Markdown", 0, 95, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 69, 164
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/code-server.md", "Markdown", 0, 41, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 21, 62
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/gitea.md", "Markdown", 0, 39, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19, 58
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/homepage.md", "Markdown", 0, 146, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 66, 212
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/index.md", "Markdown", 0, 101, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 118
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/listmonk.md", "Markdown", 0, 47, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 22, 69
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/map.md", "Markdown", 0, 70, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 27, 97
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/mini-qr.md", "Markdown", 0, 23, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 38
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/mkdocs.md", "Markdown", 0, 86, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 133
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/n8n.md", "Markdown", 0, 109, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 49, 158
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/nocodb.md", "Markdown", 0, 108, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 55, 163
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/postgresql.md", "Markdown", 0, 59, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 91
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/static-server.md", "Markdown", 0, 69, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 101
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/stylesheets/extra.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 498, 0, 0, 0, 0, 18, 82, 598
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/stylesheets/home.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 866, 0, 0, 0, 0, 54, 153, 1073
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/test.md", "Markdown", 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 11
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/mkdocs.yml", "YAML", 0, 0, 0, 167, 0, 0, 0, 0, 0, 0, 0, 0, 0, 11, 11, 189
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/404.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 251, 0, 0, 0, 0, 0, 1, 437, 689
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/adv/ansible/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 1653, 0, 0, 0, 0, 0, 1, 1326, 2980
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/adv/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 549, 0, 0, 0, 0, 0, 1, 1083, 1633
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/adv/vscode-ssh/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 1926, 0, 0, 0, 0, 0, 1, 1358, 3285
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/bundle.50899def.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 14, 0, 0, 0, 0, 0, 0, 1, 2, 17
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ar.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.da.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.de.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.du.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.el.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.es.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.fi.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.fr.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.he.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.hi.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.hu.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.hy.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.it.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ja.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.jp.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.kn.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ko.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.multi.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.nl.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.no.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.pt.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ro.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ru.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.sa.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.sv.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ta.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.te.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.th.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.tr.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 16, 1, 18
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.vi.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.zh.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/tinyseg.js", "JavaScript", 0, 0, 0, 0, 0, 0, 176, 0, 0, 0, 0, 0, 0, 21, 9, 206
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/wordcut.js", "JavaScript", 0, 0, 0, 0, 0, 0, 4882, 0, 0, 0, 0, 0, 0, 915, 911, 6708
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/workers/search.d50fe291.min.js", "JavaScript", 0, 0, 0, 0, 0, 0, 38, 0, 0, 0, 0, 0, 0, 3, 2, 43
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/admin-changemaker.lite.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/anthropics-claude-code.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/coder-code-server.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/gethomepage-homepage.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/go-gitea-gitea.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/knadh-listmonk.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/lyqht-mini-qr.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/n8n-io-n8n.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/nocodb-nocodb.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/ollama-ollama.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/squidfunk-mkdocs-material.json", "JSON", 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/stylesheets/main.7e37652d.min.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/stylesheets/palette.06af60db.min.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/2025/07/03/blog-1/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 374, 0, 0, 0, 0, 0, 1, 582, 957
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/2025/07/10/2/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 392, 0, 0, 0, 0, 0, 1, 583, 976
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/2025/08/01/3/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 455, 0, 0, 0, 0, 0, 1, 590, 1046
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/archive/2025/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 442, 0, 0, 0, 0, 0, 1, 582, 1025
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 451, 0, 0, 0, 0, 0, 1, 572, 1024
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/build/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 1312, 0, 0, 0, 0, 0, 1, 1199, 2512
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/build/map/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 983, 0, 0, 0, 0, 0, 1, 1182, 2166
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/build/server/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 1045, 0, 0, 0, 0, 0, 1, 1234, 2280
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/build/site/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 748, 0, 0, 0, 0, 0, 1, 1138, 1887
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/cloudflare-config/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 740, 0, 0, 0, 0, 0, 1, 1146, 1887
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/coder/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 1117, 0, 0, 0, 0, 0, 1, 1240, 2358
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 550, 0, 0, 0, 0, 0, 1, 1083, 1634
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/map/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 1672, 0, 0, 0, 0, 0, 1, 1346, 3019
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/mkdocs/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 833, 0, 0, 0, 0, 0, 1, 1151, 1985
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/hooks/repo_widget_hook.py", "Python", 0, 0, 0, 0, 0, 0, 0, 0, 0, 119, 0, 0, 0, 24, 16, 159
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/how to/canvass/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 275, 0, 0, 0, 0, 0, 1, 484, 760
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 1872, 0, 0, 0, 0, 0, 11, 259, 2142
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/javascripts/gitea-widget.js", "JavaScript", 0, 0, 0, 0, 0, 0, 121, 0, 0, 0, 0, 0, 0, 3, 8, 132
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/javascripts/github-widget.js", "JavaScript", 0, 0, 0, 0, 0, 0, 144, 0, 0, 0, 0, 0, 0, 10, 14, 168
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/javascripts/home.js", "JavaScript", 0, 0, 0, 0, 0, 0, 271, 0, 0, 0, 0, 0, 0, 30, 37, 338
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/manual/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 549, 0, 0, 0, 0, 0, 1, 1083, 1633
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/manual/map/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 987, 0, 0, 0, 0, 0, 1, 1202, 2190
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/overrides/lander.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 1872, 0, 0, 0, 0, 0, 11, 259, 2142
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/overrides/main.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 1, 3, 12
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/phil/cost-comparison/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 1300, 0, 0, 0, 0, 0, 1, 766, 2067
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/phil/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 717, 0, 0, 0, 0, 0, 1, 661, 1379
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/search/search_index.json", "JSON", 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/code-server/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 755, 0, 0, 0, 0, 0, 1, 1155, 1911
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/gitea/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 737, 0, 0, 0, 0, 0, 1, 1151, 1889
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/homepage/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 1119, 0, 0, 0, 0, 0, 1, 1235, 2355
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 769, 0, 0, 0, 0, 0, 1, 1113, 1883
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/listmonk/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 775, 0, 0, 0, 0, 0, 1, 1159, 1935
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/map/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 877, 0, 0, 0, 0, 0, 1, 1170, 2048
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/mini-qr/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 707, 0, 0, 0, 0, 0, 1, 1147, 1855
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/mkdocs/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 905, 0, 0, 0, 0, 0, 1, 1187, 2093
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/n8n/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 1057, 0, 0, 0, 0, 0, 1, 1223, 2281
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/nocodb/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 1040, 0, 0, 0, 0, 0, 1, 1219, 2260
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/postgresql/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 880, 0, 0, 0, 0, 0, 1, 1190, 2071
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/static-server/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 877, 0, 0, 0, 0, 0, 1, 1182, 2060
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/sitemap.xml", "XML", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 0, 0, 147
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/stylesheets/extra.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 498, 0, 0, 0, 0, 18, 82, 598
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/stylesheets/home.css", "PostCSS", 0, 0, 0, 0, 0, 0, 0, 0, 866, 0, 0, 0, 0, 54, 153, 1073
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/test/index.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 278, 0, 0, 0, 0, 0, 1, 484, 763
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/reset-site.sh", "Shell Script", 258, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 28, 73, 359
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/start-production.sh", "Shell Script", 487, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 77, 98, 662
"/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/test.md", "Markdown", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
"Total", "-", 2480, 6210, 705, 608, 2634, 33, 27027, 38504, 10405, 238, 20, 2913, 147, 4969, 47542, 144435
1 filename language Shell Script Markdown Log YAML JSON Properties JavaScript HTML PostCSS Python Docker CSV XML comment blank total
2 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/README.md Markdown 0 55 0 0 0 0 0 0 0 0 0 0 0 0 24 79
3 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/combined.log Log 0 0 13 0 0 0 0 0 0 0 0 0 0 0 3 16
4 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/config.sh Shell Script 810 0 0 0 0 0 0 0 0 0 0 0 0 177 224 1211
5 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/bookmarks.yaml YAML 0 0 0 47 0 0 0 0 0 0 0 0 0 2 5 54
6 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/custom.css PostCSS 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1
7 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/custom.js JavaScript 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1
8 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/docker.yaml YAML 0 0 0 3 0 0 0 0 0 0 0 0 0 2 2 7
9 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/kubernetes.yaml YAML 0 0 0 1 0 0 0 0 0 0 0 0 0 1 1 3
10 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/logs/homepage.log Log 0 0 692 0 0 0 0 0 0 0 0 0 0 0 14 706
11 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/services.yaml YAML 0 0 0 59 0 0 0 0 0 0 0 0 0 1 15 75
12 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/settings.yaml YAML 0 0 0 36 0 0 0 0 0 0 0 0 0 4 10 50
13 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/widgets.yaml YAML 0 0 0 26 0 0 0 0 0 0 0 0 0 2 5 33
14 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/mkdocs-site/default.conf Properties 0 0 0 0 0 33 0 0 0 0 0 0 0 7 9 49
15 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/docker-compose.yml YAML 0 0 0 238 0 0 0 0 0 0 0 0 0 2 13 253
16 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/Instuctions.md Markdown 0 56 0 0 0 0 0 0 0 0 0 0 0 0 21 77
17 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/README.md Markdown 0 851 0 0 0 0 0 0 0 0 0 0 0 0 252 1103
18 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/Dockerfile Docker 0 0 0 0 0 0 0 0 0 0 20 0 0 7 10 37
19 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/config/index.js JavaScript 0 0 0 0 0 0 118 0 0 0 0 0 0 15 18 151
20 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/authController.js JavaScript 0 0 0 0 0 0 172 0 0 0 0 0 0 13 25 210
21 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/cutsController.js JavaScript 0 0 0 0 0 0 276 0 0 0 0 0 0 41 47 364
22 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/dashboardController.js JavaScript 0 0 0 0 0 0 70 0 0 0 0 0 0 9 15 94
23 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/dataConvertController.js JavaScript 0 0 0 0 0 0 352 0 0 0 0 0 0 50 76 478
24 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/externalDataController.js JavaScript 0 0 0 0 0 0 101 0 0 0 0 0 0 11 21 133
25 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/listmonkController.js JavaScript 0 0 0 0 0 0 212 0 0 0 0 0 0 12 29 253
26 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/locationsController.js JavaScript 0 0 0 0 0 0 333 0 0 0 0 0 0 23 58 414
27 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/passwordRecoveryController.js JavaScript 0 0 0 0 0 0 45 0 0 0 0 0 0 4 12 61
28 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/publicShiftsController.js JavaScript 0 0 0 0 0 0 212 0 0 0 0 0 0 21 38 271
29 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/settingsController.js JavaScript 0 0 0 0 0 0 303 0 0 0 0 0 0 17 50 370
30 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/shiftsController.js JavaScript 0 0 0 0 0 0 643 0 0 0 0 0 0 57 123 823
31 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/usersController.js JavaScript 0 0 0 0 0 0 341 0 0 0 0 0 0 18 54 413
32 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/middleware/auth.js JavaScript 0 0 0 0 0 0 170 0 0 0 0 0 0 13 25 208
33 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/middleware/rateLimiter.js JavaScript 0 0 0 0 0 0 85 0 0 0 0 0 0 10 9 104
34 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/package-lock.json JSON 0 0 0 0 2203 0 0 0 0 0 0 0 0 0 1 2204
35 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/package.json JSON 0 0 0 0 43 0 0 0 0 0 0 0 0 0 1 44
36 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/admin.html HTML 0 0 0 0 0 0 0 1080 0 0 0 0 0 46 135 1261
37 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/REFACTORING_SUMMARY.md Markdown 0 51 0 0 0 0 0 0 0 0 0 0 0 0 13 64
38 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin.css PostCSS 0 0 0 0 0 0 0 0 12 0 0 0 0 4 3 19
39 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/cuts-shifts.css PostCSS 0 0 0 0 0 0 0 0 225 0 0 0 0 13 44 282
40 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/data-convert.css PostCSS 0 0 0 0 0 0 0 0 176 0 0 0 0 7 33 216
41 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/forms.css PostCSS 0 0 0 0 0 0 0 0 207 0 0 0 0 9 36 252
42 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/layout.css PostCSS 0 0 0 0 0 0 0 0 202 0 0 0 0 7 36 245
43 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/modals.css PostCSS 0 0 0 0 0 0 0 0 259 0 0 0 0 6 46 311
44 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/nocodb-links.css PostCSS 0 0 0 0 0 0 0 0 109 0 0 0 0 4 23 136
45 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/responsive.css PostCSS 0 0 0 0 0 0 0 0 552 0 0 0 0 28 112 692
46 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/status-messages.css PostCSS 0 0 0 0 0 0 0 0 154 0 0 0 0 9 27 190
47 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/user-management.css PostCSS 0 0 0 0 0 0 0 0 179 0 0 0 0 10 31 220
48 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/variables.css PostCSS 0 0 0 0 0 0 0 0 48 0 0 0 0 9 10 67
49 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/walk-sheet.css PostCSS 0 0 0 0 0 0 0 0 252 0 0 0 0 12 39 303
50 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/apartment-marker.css PostCSS 0 0 0 0 0 0 0 0 35 0 0 0 0 2 5 42
51 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/apartment-popup.css PostCSS 0 0 0 0 0 0 0 0 197 0 0 0 0 9 30 236
52 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/base.css PostCSS 0 0 0 0 0 0 0 0 68 0 0 0 0 22 12 102
53 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/buttons.css PostCSS 0 0 0 0 0 0 0 0 70 0 0 0 0 2 16 88
54 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/cache-busting.css PostCSS 0 0 0 0 0 0 0 0 99 0 0 0 0 2 13 114
55 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/cuts.css PostCSS 0 0 0 0 0 0 0 0 829 0 0 0 0 22 146 997
56 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/dashboard.css PostCSS 0 0 0 0 0 0 0 0 129 0 0 0 0 3 29 161
57 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/doc-search.css PostCSS 0 0 0 0 0 0 0 0 123 0 0 0 0 2 20 145
58 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/forms.css PostCSS 0 0 0 0 0 0 0 0 105 0 0 0 0 2 18 125
59 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/layout.css PostCSS 0 0 0 0 0 0 0 0 83 0 0 0 0 5 11 99
60 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/leaflet-custom.css PostCSS 0 0 0 0 0 0 0 0 147 0 0 0 0 20 32 199
61 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/listmonk.css PostCSS 0 0 0 0 0 0 0 0 295 0 0 0 0 4 55 354
62 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/map-controls.css PostCSS 0 0 0 0 0 0 0 0 281 0 0 0 0 13 45 339
63 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/mobile-ui.css PostCSS 0 0 0 0 0 0 0 0 205 0 0 0 0 8 36 249
64 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/modal.css PostCSS 0 0 0 0 0 0 0 0 73 0 0 0 0 1 10 84
65 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/nocodb-dropdown.css PostCSS 0 0 0 0 0 0 0 0 134 0 0 0 0 4 24 162
66 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/notifications.css PostCSS 0 0 0 0 0 0 0 0 105 0 0 0 0 5 16 126
67 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/print.css PostCSS 0 0 0 0 0 0 0 0 11 0 0 0 0 1 2 14
68 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/qr-code.css PostCSS 0 0 0 0 0 0 0 0 59 0 0 0 0 3 13 75
69 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/responsive.css PostCSS 0 0 0 0 0 0 0 0 150 0 0 0 0 12 29 191
70 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/start-location-marker.css PostCSS 0 0 0 0 0 0 0 0 65 0 0 0 0 3 8 76
71 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/temp-user.css PostCSS 0 0 0 0 0 0 0 0 46 0 0 0 0 6 5 57
72 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/unified-search.css PostCSS 0 0 0 0 0 0 0 0 580 0 0 0 0 30 99 709
73 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/public-shifts.css PostCSS 0 0 0 0 0 0 0 0 418 0 0 0 0 24 83 525
74 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/shifts.css PostCSS 0 0 0 0 0 0 0 0 608 0 0 0 0 21 118 747
75 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/style.css PostCSS 0 0 0 0 0 0 0 0 21 0 0 0 0 0 1 22
76 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/user.css PostCSS 0 0 0 0 0 0 0 0 364 0 0 0 0 13 70 447
77 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/index.html HTML 0 0 0 0 0 0 0 384 0 0 0 0 0 28 45 457
78 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-auth.js JavaScript 0 0 0 0 0 0 63 0 0 0 0 0 0 11 14 88
79 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-core.js JavaScript 0 0 0 0 0 0 239 0 0 0 0 0 0 46 40 325
80 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-cuts.js JavaScript 0 0 0 0 0 0 1505 0 0 0 0 0 0 184 302 1991
81 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-email.js JavaScript 0 0 0 0 0 0 319 0 0 0 0 0 0 29 47 395
82 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-map.js JavaScript 0 0 0 0 0 0 178 0 0 0 0 0 0 26 46 250
83 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-shift-volunteers.js JavaScript 0 0 0 0 0 0 416 0 0 0 0 0 0 49 66 531
84 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-shifts.js JavaScript 0 0 0 0 0 0 330 0 0 0 0 0 0 35 55 420
85 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-users.js JavaScript 0 0 0 0 0 0 305 0 0 0 0 0 0 16 45 366
86 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-walksheet.js JavaScript 0 0 0 0 0 0 387 0 0 0 0 0 0 35 50 472
87 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin.js JavaScript 0 0 0 0 0 0 2309 0 0 0 0 0 0 178 288 2775
88 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/auth.js JavaScript 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1
89 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/navigation.js JavaScript 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1
90 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/shifts.js JavaScript 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1
91 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/startLocation.js JavaScript 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1
92 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/users.js JavaScript 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1
93 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/utils.js JavaScript 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1
94 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/walkSheet.js JavaScript 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1
95 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/auth.js JavaScript 0 0 0 0 0 0 181 0 0 0 0 0 0 28 34 243
96 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/cache-manager.js JavaScript 0 0 0 0 0 0 219 0 0 0 0 0 0 71 28 318
97 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/config.js JavaScript 0 0 0 0 0 0 32 0 0 0 0 0 0 3 3 38
98 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/cut-controls.js JavaScript 0 0 0 0 0 0 982 0 0 0 0 0 0 163 145 1290
99 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/cut-drawing.js JavaScript 0 0 0 0 0 0 206 0 0 0 0 0 0 72 59 337
100 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/cut-manager.js JavaScript 0 0 0 0 0 0 359 0 0 0 0 0 0 96 79 534
101 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/dashboard.js JavaScript 0 0 0 0 0 0 333 0 0 0 0 0 0 23 33 389
102 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/data-convert.js JavaScript 0 0 0 0 0 0 510 0 0 0 0 0 0 64 118 692
103 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/database-search.js JavaScript 0 0 0 0 0 0 186 0 0 0 0 0 0 76 51 313
104 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/external-layers.js JavaScript 0 0 0 0 0 0 513 0 0 0 0 0 0 39 45 597
105 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/listmonk-admin.js JavaScript 0 0 0 0 0 0 410 0 0 0 0 0 0 76 85 571
106 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/listmonk-status.js JavaScript 0 0 0 0 0 0 169 0 0 0 0 0 0 25 32 226
107 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/location-manager.js JavaScript 0 0 0 0 0 0 998 0 0 0 0 0 0 56 91 1145
108 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/main.js JavaScript 0 0 0 0 0 0 178 0 0 0 0 0 0 40 36 254
109 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/map-manager.js JavaScript 0 0 0 0 0 0 82 0 0 0 0 0 0 12 19 113
110 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/map-search.js JavaScript 0 0 0 0 0 0 120 0 0 0 0 0 0 44 36 200
111 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/mkdocs-search.js JavaScript 0 0 0 0 0 0 263 0 0 0 0 0 0 36 52 351
112 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/public-shifts.js JavaScript 0 0 0 0 0 0 518 0 0 0 0 0 0 15 25 558
113 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/search-manager.js JavaScript 0 0 0 0 0 0 407 0 0 0 0 0 0 138 100 645
114 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/shifts.js JavaScript 0 0 0 0 0 0 1059 0 0 0 0 0 0 35 55 1149
115 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/ui-controls.js JavaScript 0 0 0 0 0 0 572 0 0 0 0 0 0 74 101 747
116 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/user.js JavaScript 0 0 0 0 0 0 81 0 0 0 0 0 0 12 19 112
117 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/utils.js JavaScript 0 0 0 0 0 0 96 0 0 0 0 0 0 21 25 142
118 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/login.html HTML 0 0 0 0 0 0 0 459 0 0 0 0 0 3 76 538
119 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/public-shifts.html HTML 0 0 0 0 0 0 0 88 0 0 0 0 0 4 7 99
120 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/shifts.html HTML 0 0 0 0 0 0 0 73 0 0 0 0 0 5 8 86
121 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/user.html HTML 0 0 0 0 0 0 0 47 0 0 0 0 0 7 9 63
122 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/admin.js JavaScript 0 0 0 0 0 0 80 0 0 0 0 0 0 15 16 111
123 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/auth.js JavaScript 0 0 0 0 0 0 14 0 0 0 0 0 0 5 6 25
124 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/cuts.js JavaScript 0 0 0 0 0 0 18 0 0 0 0 0 0 6 7 31
125 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/dashboard.js JavaScript 0 0 0 0 0 0 5 0 0 0 0 0 0 0 3 8
126 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/dataConvert.js JavaScript 0 0 0 0 0 0 21 0 0 0 0 0 0 4 6 31
127 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/debug.js JavaScript 0 0 0 0 0 0 236 0 0 0 0 0 0 10 36 282
128 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/external.js JavaScript 0 0 0 0 0 0 7 0 0 0 0 0 0 2 4 13
129 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/geocoding.js JavaScript 0 0 0 0 0 0 120 0 0 0 0 0 0 24 31 175
130 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/index.js JavaScript 0 0 0 0 0 0 153 0 0 0 0 0 0 29 36 218
131 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/listmonk.js JavaScript 0 0 0 0 0 0 12 0 0 0 0 0 0 5 9 26
132 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/locations.js JavaScript 0 0 0 0 0 0 11 0 0 0 0 0 0 5 6 22
133 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/public.js JavaScript 0 0 0 0 0 0 8 0 0 0 0 0 0 1 3 12
134 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/publicShifts.js JavaScript 0 0 0 0 0 0 8 0 0 0 0 0 0 1 2 11
135 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/qr.js JavaScript 0 0 0 0 0 0 39 0 0 0 0 0 0 1 8 48
136 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/settings.js JavaScript 0 0 0 0 0 0 8 0 0 0 0 0 0 2 3 13
137 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/shifts.js JavaScript 0 0 0 0 0 0 16 0 0 0 0 0 0 4 5 25
138 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/users.js JavaScript 0 0 0 0 0 0 11 0 0 0 0 0 0 6 7 24
139 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/server.js JavaScript 0 0 0 0 0 0 249 0 0 0 0 0 0 43 49 341
140 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/accountExpiration.js JavaScript 0 0 0 0 0 0 59 0 0 0 0 0 0 11 19 89
141 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/email.js JavaScript 0 0 0 0 0 0 109 0 0 0 0 0 0 4 19 132
142 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/emailTemplates.js JavaScript 0 0 0 0 0 0 59 0 0 0 0 0 0 10 16 85
143 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/geocoding.js JavaScript 0 0 0 0 0 0 219 0 0 0 0 0 0 51 44 314
144 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/listmonk.js JavaScript 0 0 0 0 0 0 412 0 0 0 0 0 0 36 66 514
145 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/nocodb.js JavaScript 0 0 0 0 0 0 172 0 0 0 0 0 0 22 36 230
146 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/qrcode.js JavaScript 0 0 0 0 0 0 110 0 0 0 0 0 0 33 20 163
147 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/socrata.js JavaScript 0 0 0 0 0 0 49 0 0 0 0 0 0 9 10 68
148 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/login-details.html HTML 0 0 0 0 0 0 0 113 0 0 0 0 0 0 4 117
149 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/password-recovery.html HTML 0 0 0 0 0 0 0 79 0 0 0 0 0 0 1 80
150 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/public-shift-signup-existing.html HTML 0 0 0 0 0 0 0 161 0 0 0 0 0 0 22 183
151 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/public-shift-signup-new.html HTML 0 0 0 0 0 0 0 31 0 0 0 0 0 0 5 36
152 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/shift-details.html HTML 0 0 0 0 0 0 0 152 0 0 0 0 0 0 5 157
153 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/user-broadcast.html HTML 0 0 0 0 0 0 0 108 0 0 0 0 0 0 2 110
154 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/utils/cacheBusting.js JavaScript 0 0 0 0 0 0 105 0 0 0 0 0 0 51 24 180
155 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/utils/helpers.js JavaScript 0 0 0 0 0 0 149 0 0 0 0 0 0 25 28 202
156 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/utils/logger.js JavaScript 0 0 0 0 0 0 38 0 0 0 0 0 0 2 3 43
157 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/build-nocodb.sh Shell Script 925 0 0 0 0 0 0 0 0 0 0 0 0 60 93 1078
158 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City of Edmonton - Neighbourhoods (Centroid Point)_20250807.geojson JSON 0 0 0 0 3 0 0 0 0 0 0 0 0 0 1 4
159 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City of Edmonton - Neighbourhoods_20250807.geojson JSON 0 0 0 0 5 0 0 0 0 0 0 0 0 0 0 5
160 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City of Edmonton Ward Boundary and Council Composition_Current_20250807.geojson JSON 0 0 0 0 5 0 0 0 0 0 0 0 0 0 0 5
161 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City_of_Edmonton_-_Neighbourhoods_20250807.csv CSV 0 0 0 0 0 0 0 0 0 0 0 689 0 0 1 690
162 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City_of_Edmonton_-_Neighbourhoods__Centroid_Point__20250807.csv CSV 0 0 0 0 0 0 0 0 0 0 0 407 0 0 1 408
163 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City_of_Edmonton_Ward_Boundary_and_Council_Composition_Current_20250807.csv CSV 0 0 0 0 0 0 0 0 0 0 0 13 0 0 1 14
164 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/convert_edmonton_optimized.js JavaScript 0 0 0 0 0 0 185 0 0 0 0 0 0 21 43 249
165 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/convert_ward_boundaries_optimized.js JavaScript 0 0 0 0 0 0 173 0 0 0 0 0 0 24 45 242
166 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/edmonton_neighborhoods_nocodb_optimized.csv CSV 0 0 0 0 0 0 0 0 0 0 0 689 0 0 0 689
167 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/edmonton_nocodb_optimized.csv CSV 0 0 0 0 0 0 0 0 0 0 0 1095 0 0 406 1501
168 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/edmonton_ward_boundaries_optimized.csv CSV 0 0 0 0 0 0 0 0 0 0 0 13 0 0 0 13
169 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/exampledata.csv CSV 0 0 0 0 0 0 0 0 0 0 0 7 0 0 0 7
170 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/docker-compose.yml YAML 0 0 0 31 0 0 0 0 0 0 0 0 0 0 3 34
171 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/files-explainer.md Markdown 0 292 0 0 0 0 0 0 0 0 0 0 0 0 293 585
172 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/ADMIN_IMPLEMENTATION.md Markdown 0 102 0 0 0 0 0 0 0 0 0 0 0 0 28 130
173 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/CUT_IMPLEMENTATION_SUMMARY.md Markdown 0 156 0 0 0 0 0 0 0 0 0 0 0 0 34 190
174 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/CUT_PUBLIC_IMPLEMENTATION.md Markdown 0 124 0 0 0 0 0 0 0 0 0 0 0 5 31 160
175 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/CUT_SIMPLIFICATION_SUMMARY.md Markdown 0 65 0 0 0 0 0 0 0 0 0 0 0 0 21 86
176 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/LISTMONK_INTEGRATION_GUIDE.md Markdown 0 249 0 0 0 0 0 0 0 0 0 0 0 0 70 319
177 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/SHIFT_PERFORMANCE_FIX.md Markdown 0 71 0 0 0 0 0 0 0 0 0 0 0 0 22 93
178 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/TEMP_USER_IMPLEMENTATION.md Markdown 0 88 0 0 0 0 0 0 0 0 0 0 0 0 28 116
179 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/TEMP_USER_TEST.md Markdown 0 113 0 0 0 0 0 0 0 0 0 0 0 0 34 147
180 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/build-nocodb.md Markdown 0 374 0 0 0 0 0 0 0 0 0 0 0 0 67 441
181 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/package-lock.json JSON 0 0 0 0 17 0 0 0 0 0 0 0 0 0 1 18
182 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/package.json JSON 0 0 0 0 5 0 0 0 0 0 0 0 0 0 1 6
183 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/adv/ansible.md Markdown 0 373 0 0 0 0 0 0 0 0 0 0 0 0 152 525
184 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/adv/index.md Markdown 0 2 0 0 0 0 0 0 0 0 0 0 0 0 1 3
185 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/adv/vscode-ssh.md Markdown 0 511 0 0 0 0 0 0 0 0 0 0 0 0 174 685
186 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
187 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/anthropics-claude-code.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
188 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/coder-code-server.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
189 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/gethomepage-homepage.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
190 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/go-gitea-gitea.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
191 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/knadh-listmonk.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
192 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/lyqht-mini-qr.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
193 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/n8n-io-n8n.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
194 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/nocodb-nocodb.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
195 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/ollama-ollama.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
196 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/squidfunk-mkdocs-material.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
197 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/blog/index.md Markdown 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1
198 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/blog/posts/1.md Markdown 0 6 0 0 0 0 0 0 0 0 0 0 0 0 3 9
199 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/blog/posts/2.md Markdown 0 10 0 0 0 0 0 0 0 0 0 0 0 0 4 14
200 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/blog/posts/3.md Markdown 0 54 0 0 0 0 0 0 0 0 0 0 0 0 21 75
201 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/build/index.md Markdown 0 336 0 0 0 0 0 0 0 0 0 0 0 0 150 486
202 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/build/map.md Markdown 0 155 0 0 0 0 0 0 0 0 0 0 0 0 62 217
203 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/build/server.md Markdown 0 122 0 0 0 0 0 0 0 0 0 0 0 0 27 149
204 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/build/site.md Markdown 0 74 0 0 0 0 0 0 0 0 0 0 0 0 34 108
205 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/cloudflare-config.md Markdown 0 45 0 0 0 0 0 0 0 0 0 0 0 0 16 61
206 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/coder.md Markdown 0 139 0 0 0 0 0 0 0 0 0 0 0 0 77 216
207 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/index.md Markdown 0 3 0 0 0 0 0 0 0 0 0 0 0 0 4 7
208 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/map.md Markdown 0 299 0 0 0 0 0 0 0 0 0 0 0 0 91 390
209 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/mkdocs.md Markdown 0 102 0 0 0 0 0 0 0 0 0 0 0 0 42 144
210 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/hooks/repo_widget_hook.py Python 0 0 0 0 0 0 0 0 0 119 0 0 0 24 16 159
211 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/how to/canvass.md Markdown 0 2 0 0 0 0 0 0 0 0 0 0 0 0 3 5
212 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/index.md Markdown 0 85 0 0 0 0 0 0 0 0 0 0 0 0 29 114
213 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/javascripts/gitea-widget.js JavaScript 0 0 0 0 0 0 121 0 0 0 0 0 0 3 8 132
214 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/javascripts/github-widget.js JavaScript 0 0 0 0 0 0 144 0 0 0 0 0 0 10 14 168
215 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/javascripts/home.js JavaScript 0 0 0 0 0 0 271 0 0 0 0 0 0 30 37 338
216 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/manual/index.md Markdown 0 2 0 0 0 0 0 0 0 0 0 0 0 0 1 3
217 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/manual/map.md Markdown 0 91 0 0 0 0 0 0 0 0 0 0 0 0 48 139
218 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/overrides/lander.html HTML 0 0 0 0 0 0 0 1872 0 0 0 0 0 11 259 2142
219 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/overrides/main.html HTML 0 0 0 0 0 0 0 8 0 0 0 0 0 1 3 12
220 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/phil/cost-comparison.md Markdown 0 153 0 0 0 0 0 0 0 0 0 0 0 0 51 204
221 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/phil/index.md Markdown 0 95 0 0 0 0 0 0 0 0 0 0 0 0 69 164
222 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/code-server.md Markdown 0 41 0 0 0 0 0 0 0 0 0 0 0 0 21 62
223 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/gitea.md Markdown 0 39 0 0 0 0 0 0 0 0 0 0 0 0 19 58
224 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/homepage.md Markdown 0 146 0 0 0 0 0 0 0 0 0 0 0 0 66 212
225 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/index.md Markdown 0 101 0 0 0 0 0 0 0 0 0 0 0 0 17 118
226 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/listmonk.md Markdown 0 47 0 0 0 0 0 0 0 0 0 0 0 0 22 69
227 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/map.md Markdown 0 70 0 0 0 0 0 0 0 0 0 0 0 0 27 97
228 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/mini-qr.md Markdown 0 23 0 0 0 0 0 0 0 0 0 0 0 0 15 38
229 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/mkdocs.md Markdown 0 86 0 0 0 0 0 0 0 0 0 0 0 0 47 133
230 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/n8n.md Markdown 0 109 0 0 0 0 0 0 0 0 0 0 0 0 49 158
231 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/nocodb.md Markdown 0 108 0 0 0 0 0 0 0 0 0 0 0 0 55 163
232 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/postgresql.md Markdown 0 59 0 0 0 0 0 0 0 0 0 0 0 0 32 91
233 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/static-server.md Markdown 0 69 0 0 0 0 0 0 0 0 0 0 0 0 32 101
234 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/stylesheets/extra.css PostCSS 0 0 0 0 0 0 0 0 498 0 0 0 0 18 82 598
235 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/stylesheets/home.css PostCSS 0 0 0 0 0 0 0 0 866 0 0 0 0 54 153 1073
236 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/test.md Markdown 0 5 0 0 0 0 0 0 0 0 0 0 0 0 6 11
237 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/mkdocs.yml YAML 0 0 0 167 0 0 0 0 0 0 0 0 0 11 11 189
238 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/404.html HTML 0 0 0 0 0 0 0 251 0 0 0 0 0 1 437 689
239 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/adv/ansible/index.html HTML 0 0 0 0 0 0 0 1653 0 0 0 0 0 1 1326 2980
240 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/adv/index.html HTML 0 0 0 0 0 0 0 549 0 0 0 0 0 1 1083 1633
241 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/adv/vscode-ssh/index.html HTML 0 0 0 0 0 0 0 1926 0 0 0 0 0 1 1358 3285
242 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/bundle.50899def.min.js JavaScript 0 0 0 0 0 0 14 0 0 0 0 0 0 1 2 17
243 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ar.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
244 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.da.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
245 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.de.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
246 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.du.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
247 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.el.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
248 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.es.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
249 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.fi.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
250 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.fr.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
251 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.he.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
252 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.hi.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
253 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.hu.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
254 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.hy.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
255 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.it.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
256 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ja.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
257 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.jp.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
258 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.kn.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
259 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ko.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
260 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.multi.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
261 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.nl.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
262 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.no.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
263 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.pt.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
264 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ro.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
265 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ru.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
266 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.sa.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
267 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
268 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.sv.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
269 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ta.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
270 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.te.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
271 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.th.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
272 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.tr.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 16 1 18
273 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.vi.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
274 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.zh.min.js JavaScript 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1
275 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/tinyseg.js JavaScript 0 0 0 0 0 0 176 0 0 0 0 0 0 21 9 206
276 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/wordcut.js JavaScript 0 0 0 0 0 0 4882 0 0 0 0 0 0 915 911 6708
277 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/workers/search.d50fe291.min.js JavaScript 0 0 0 0 0 0 38 0 0 0 0 0 0 3 2 43
278 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/admin-changemaker.lite.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
279 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/anthropics-claude-code.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
280 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/coder-code-server.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
281 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/gethomepage-homepage.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
282 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/go-gitea-gitea.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
283 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/knadh-listmonk.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
284 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/lyqht-mini-qr.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
285 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/n8n-io-n8n.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
286 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/nocodb-nocodb.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
287 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/ollama-ollama.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
288 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/squidfunk-mkdocs-material.json JSON 0 0 0 0 16 0 0 0 0 0 0 0 0 0 0 16
289 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/stylesheets/main.7e37652d.min.css PostCSS 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1
290 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/stylesheets/palette.06af60db.min.css PostCSS 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1
291 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/2025/07/03/blog-1/index.html HTML 0 0 0 0 0 0 0 374 0 0 0 0 0 1 582 957
292 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/2025/07/10/2/index.html HTML 0 0 0 0 0 0 0 392 0 0 0 0 0 1 583 976
293 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/2025/08/01/3/index.html HTML 0 0 0 0 0 0 0 455 0 0 0 0 0 1 590 1046
294 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/archive/2025/index.html HTML 0 0 0 0 0 0 0 442 0 0 0 0 0 1 582 1025
295 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/index.html HTML 0 0 0 0 0 0 0 451 0 0 0 0 0 1 572 1024
296 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/build/index.html HTML 0 0 0 0 0 0 0 1312 0 0 0 0 0 1 1199 2512
297 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/build/map/index.html HTML 0 0 0 0 0 0 0 983 0 0 0 0 0 1 1182 2166
298 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/build/server/index.html HTML 0 0 0 0 0 0 0 1045 0 0 0 0 0 1 1234 2280
299 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/build/site/index.html HTML 0 0 0 0 0 0 0 748 0 0 0 0 0 1 1138 1887
300 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/cloudflare-config/index.html HTML 0 0 0 0 0 0 0 740 0 0 0 0 0 1 1146 1887
301 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/coder/index.html HTML 0 0 0 0 0 0 0 1117 0 0 0 0 0 1 1240 2358
302 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/index.html HTML 0 0 0 0 0 0 0 550 0 0 0 0 0 1 1083 1634
303 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/map/index.html HTML 0 0 0 0 0 0 0 1672 0 0 0 0 0 1 1346 3019
304 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/mkdocs/index.html HTML 0 0 0 0 0 0 0 833 0 0 0 0 0 1 1151 1985
305 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/hooks/repo_widget_hook.py Python 0 0 0 0 0 0 0 0 0 119 0 0 0 24 16 159
306 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/how to/canvass/index.html HTML 0 0 0 0 0 0 0 275 0 0 0 0 0 1 484 760
307 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/index.html HTML 0 0 0 0 0 0 0 1872 0 0 0 0 0 11 259 2142
308 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/javascripts/gitea-widget.js JavaScript 0 0 0 0 0 0 121 0 0 0 0 0 0 3 8 132
309 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/javascripts/github-widget.js JavaScript 0 0 0 0 0 0 144 0 0 0 0 0 0 10 14 168
310 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/javascripts/home.js JavaScript 0 0 0 0 0 0 271 0 0 0 0 0 0 30 37 338
311 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/manual/index.html HTML 0 0 0 0 0 0 0 549 0 0 0 0 0 1 1083 1633
312 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/manual/map/index.html HTML 0 0 0 0 0 0 0 987 0 0 0 0 0 1 1202 2190
313 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/overrides/lander.html HTML 0 0 0 0 0 0 0 1872 0 0 0 0 0 11 259 2142
314 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/overrides/main.html HTML 0 0 0 0 0 0 0 8 0 0 0 0 0 1 3 12
315 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/phil/cost-comparison/index.html HTML 0 0 0 0 0 0 0 1300 0 0 0 0 0 1 766 2067
316 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/phil/index.html HTML 0 0 0 0 0 0 0 717 0 0 0 0 0 1 661 1379
317 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/search/search_index.json JSON 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 1
318 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/code-server/index.html HTML 0 0 0 0 0 0 0 755 0 0 0 0 0 1 1155 1911
319 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/gitea/index.html HTML 0 0 0 0 0 0 0 737 0 0 0 0 0 1 1151 1889
320 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/homepage/index.html HTML 0 0 0 0 0 0 0 1119 0 0 0 0 0 1 1235 2355
321 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/index.html HTML 0 0 0 0 0 0 0 769 0 0 0 0 0 1 1113 1883
322 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/listmonk/index.html HTML 0 0 0 0 0 0 0 775 0 0 0 0 0 1 1159 1935
323 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/map/index.html HTML 0 0 0 0 0 0 0 877 0 0 0 0 0 1 1170 2048
324 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/mini-qr/index.html HTML 0 0 0 0 0 0 0 707 0 0 0 0 0 1 1147 1855
325 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/mkdocs/index.html HTML 0 0 0 0 0 0 0 905 0 0 0 0 0 1 1187 2093
326 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/n8n/index.html HTML 0 0 0 0 0 0 0 1057 0 0 0 0 0 1 1223 2281
327 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/nocodb/index.html HTML 0 0 0 0 0 0 0 1040 0 0 0 0 0 1 1219 2260
328 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/postgresql/index.html HTML 0 0 0 0 0 0 0 880 0 0 0 0 0 1 1190 2071
329 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/static-server/index.html HTML 0 0 0 0 0 0 0 877 0 0 0 0 0 1 1182 2060
330 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/sitemap.xml XML 0 0 0 0 0 0 0 0 0 0 0 0 147 0 0 147
331 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/stylesheets/extra.css PostCSS 0 0 0 0 0 0 0 0 498 0 0 0 0 18 82 598
332 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/stylesheets/home.css PostCSS 0 0 0 0 0 0 0 0 866 0 0 0 0 54 153 1073
333 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/test/index.html HTML 0 0 0 0 0 0 0 278 0 0 0 0 0 1 484 763
334 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/reset-site.sh Shell Script 258 0 0 0 0 0 0 0 0 0 0 0 0 28 73 359
335 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/start-production.sh Shell Script 487 0 0 0 0 0 0 0 0 0 0 0 0 77 98 662
336 /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/test.md Markdown 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1
337 Total - 2480 6210 705 608 2634 33 27027 38504 10405 238 20 2913 147 4969 47542 144435

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,148 @@
# Summary
Date : 2025-09-05 12:42:08
Directory /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite
Total : 335 files, 91924 codes, 4969 comments, 47542 blanks, all 144435 lines
Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
## Languages
| language | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| HTML | 53 | 38,504 | 164 | 37,745 | 76,413 |
| JavaScript | 129 | 27,027 | 3,870 | 4,499 | 35,396 |
| PostCSS | 46 | 10,405 | 501 | 1,857 | 12,763 |
| Markdown | 50 | 6,210 | 5 | 2,406 | 8,621 |
| CSV | 7 | 2,913 | 0 | 409 | 3,322 |
| JSON | 30 | 2,634 | 0 | 5 | 2,639 |
| Shell Script | 4 | 2,480 | 342 | 488 | 3,310 |
| Log | 2 | 705 | 0 | 17 | 722 |
| YAML | 9 | 608 | 25 | 65 | 698 |
| Python | 2 | 238 | 48 | 32 | 318 |
| XML | 1 | 147 | 0 | 0 | 147 |
| Properties | 1 | 33 | 7 | 9 | 49 |
| Docker | 1 | 20 | 7 | 10 | 37 |
## Directories
| path | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| . | 335 | 91,924 | 4,969 | 47,542 | 144,435 |
| . (Files) | 7 | 1,862 | 284 | 435 | 2,581 |
| configs | 10 | 897 | 19 | 63 | 979 |
| configs/homepage | 9 | 864 | 12 | 54 | 930 |
| configs/homepage (Files) | 8 | 172 | 12 | 40 | 224 |
| configs/homepage/logs | 1 | 692 | 0 | 14 | 706 |
| configs/mkdocs-site | 1 | 33 | 7 | 9 | 49 |
| map | 167 | 40,025 | 3,126 | 6,580 | 49,731 |
| map (Files) | 7 | 2,177 | 60 | 664 | 2,901 |
| map/app | 139 | 33,222 | 3,016 | 5,083 | 41,321 |
| map/app (Files) | 4 | 2,515 | 50 | 61 | 2,626 |
| map/app/config | 1 | 118 | 15 | 18 | 151 |
| map/app/controllers | 12 | 3,060 | 276 | 548 | 3,884 |
| map/app/middleware | 2 | 255 | 23 | 34 | 312 |
| map/app/public | 86 | 24,382 | 2,278 | 3,910 | 30,570 |
| map/app/public (Files) | 6 | 2,131 | 93 | 280 | 2,504 |
| map/app/public/css | 40 | 7,726 | 357 | 1,399 | 9,482 |
| map/app/public/css (Files) | 6 | 1,474 | 62 | 288 | 1,824 |
| map/app/public/css/admin | 11 | 2,363 | 114 | 437 | 2,914 |
| map/app/public/css/modules | 23 | 3,889 | 181 | 674 | 4,744 |
| map/app/public/js | 40 | 14,525 | 1,828 | 2,231 | 18,584 |
| map/app/public/js (Files) | 33 | 14,525 | 1,828 | 2,224 | 18,577 |
| map/app/public/js/admin | 7 | 0 | 0 | 7 | 7 |
| map/app/routes | 17 | 767 | 120 | 188 | 1,075 |
| map/app/services | 8 | 1,189 | 176 | 230 | 1,595 |
| map/app/templates | 6 | 644 | 0 | 39 | 683 |
| map/app/templates/email | 6 | 644 | 0 | 39 | 683 |
| map/app/utils | 3 | 292 | 78 | 55 | 425 |
| map/data | 12 | 3,284 | 45 | 498 | 3,827 |
| map/instruct | 9 | 1,342 | 5 | 335 | 1,682 |
| mkdocs | 151 | 49,140 | 1,540 | 40,464 | 91,144 |
| mkdocs (Files) | 1 | 167 | 11 | 11 | 189 |
| mkdocs/docs | 54 | 7,637 | 151 | 2,040 | 9,828 |
| mkdocs/docs (Files) | 2 | 90 | 0 | 35 | 125 |
| mkdocs/docs/adv | 3 | 886 | 0 | 327 | 1,213 |
| mkdocs/docs/assets | 11 | 176 | 0 | 0 | 176 |
| mkdocs/docs/assets/repo-data | 11 | 176 | 0 | 0 | 176 |
| mkdocs/docs/blog | 4 | 70 | 0 | 29 | 99 |
| mkdocs/docs/blog (Files) | 1 | 0 | 0 | 1 | 1 |
| mkdocs/docs/blog/posts | 3 | 70 | 0 | 28 | 98 |
| mkdocs/docs/build | 4 | 687 | 0 | 273 | 960 |
| mkdocs/docs/config | 5 | 588 | 0 | 230 | 818 |
| mkdocs/docs/hooks | 1 | 119 | 24 | 16 | 159 |
| mkdocs/docs/how to | 1 | 2 | 0 | 3 | 5 |
| mkdocs/docs/javascripts | 3 | 536 | 43 | 59 | 638 |
| mkdocs/docs/manual | 2 | 93 | 0 | 49 | 142 |
| mkdocs/docs/overrides | 2 | 1,880 | 12 | 262 | 2,154 |
| mkdocs/docs/phil | 2 | 248 | 0 | 120 | 368 |
| mkdocs/docs/services | 12 | 898 | 0 | 402 | 1,300 |
| mkdocs/docs/stylesheets | 2 | 1,364 | 72 | 235 | 1,671 |
| mkdocs/site | 96 | 41,336 | 1,378 | 38,413 | 81,127 |
| mkdocs/site (Files) | 3 | 2,270 | 12 | 696 | 2,978 |
| mkdocs/site/adv | 3 | 4,128 | 3 | 3,767 | 7,898 |
| mkdocs/site/adv (Files) | 1 | 549 | 1 | 1,083 | 1,633 |
| mkdocs/site/adv/ansible | 1 | 1,653 | 1 | 1,326 | 2,980 |
| mkdocs/site/adv/vscode-ssh | 1 | 1,926 | 1 | 1,358 | 3,285 |
| mkdocs/site/assets | 49 | 5,320 | 1,180 | 939 | 7,439 |
| mkdocs/site/assets/javascripts | 36 | 5,142 | 1,180 | 939 | 7,261 |
| mkdocs/site/assets/javascripts (Files) | 1 | 14 | 1 | 2 | 17 |
| mkdocs/site/assets/javascripts/lunr | 34 | 5,090 | 1,176 | 935 | 7,201 |
| mkdocs/site/assets/javascripts/lunr (Files) | 2 | 5,058 | 936 | 920 | 6,914 |
| mkdocs/site/assets/javascripts/lunr/min | 32 | 32 | 240 | 15 | 287 |
| mkdocs/site/assets/javascripts/workers | 1 | 38 | 3 | 2 | 43 |
| mkdocs/site/assets/repo-data | 11 | 176 | 0 | 0 | 176 |
| mkdocs/site/assets/stylesheets | 2 | 2 | 0 | 0 | 2 |
| mkdocs/site/blog | 5 | 2,114 | 5 | 2,909 | 5,028 |
| mkdocs/site/blog (Files) | 1 | 451 | 1 | 572 | 1,024 |
| mkdocs/site/blog/2025 | 3 | 1,221 | 3 | 1,755 | 2,979 |
| mkdocs/site/blog/2025/07 | 2 | 766 | 2 | 1,165 | 1,933 |
| mkdocs/site/blog/2025/07/03 | 1 | 374 | 1 | 582 | 957 |
| mkdocs/site/blog/2025/07/03/blog-1 | 1 | 374 | 1 | 582 | 957 |
| mkdocs/site/blog/2025/07/10 | 1 | 392 | 1 | 583 | 976 |
| mkdocs/site/blog/2025/07/10/2 | 1 | 392 | 1 | 583 | 976 |
| mkdocs/site/blog/2025/08 | 1 | 455 | 1 | 590 | 1,046 |
| mkdocs/site/blog/2025/08/01 | 1 | 455 | 1 | 590 | 1,046 |
| mkdocs/site/blog/2025/08/01/3 | 1 | 455 | 1 | 590 | 1,046 |
| mkdocs/site/blog/archive | 1 | 442 | 1 | 582 | 1,025 |
| mkdocs/site/blog/archive/2025 | 1 | 442 | 1 | 582 | 1,025 |
| mkdocs/site/build | 4 | 4,088 | 4 | 4,753 | 8,845 |
| mkdocs/site/build (Files) | 1 | 1,312 | 1 | 1,199 | 2,512 |
| mkdocs/site/build/map | 1 | 983 | 1 | 1,182 | 2,166 |
| mkdocs/site/build/server | 1 | 1,045 | 1 | 1,234 | 2,280 |
| mkdocs/site/build/site | 1 | 748 | 1 | 1,138 | 1,887 |
| mkdocs/site/config | 5 | 4,912 | 5 | 5,966 | 10,883 |
| mkdocs/site/config (Files) | 1 | 550 | 1 | 1,083 | 1,634 |
| mkdocs/site/config/cloudflare-config | 1 | 740 | 1 | 1,146 | 1,887 |
| mkdocs/site/config/coder | 1 | 1,117 | 1 | 1,240 | 2,358 |
| mkdocs/site/config/map | 1 | 1,672 | 1 | 1,346 | 3,019 |
| mkdocs/site/config/mkdocs | 1 | 833 | 1 | 1,151 | 1,985 |
| mkdocs/site/hooks | 1 | 119 | 24 | 16 | 159 |
| mkdocs/site/how to | 1 | 275 | 1 | 484 | 760 |
| mkdocs/site/how to/canvass | 1 | 275 | 1 | 484 | 760 |
| mkdocs/site/javascripts | 3 | 536 | 43 | 59 | 638 |
| mkdocs/site/manual | 2 | 1,536 | 2 | 2,285 | 3,823 |
| mkdocs/site/manual (Files) | 1 | 549 | 1 | 1,083 | 1,633 |
| mkdocs/site/manual/map | 1 | 987 | 1 | 1,202 | 2,190 |
| mkdocs/site/overrides | 2 | 1,880 | 12 | 262 | 2,154 |
| mkdocs/site/phil | 2 | 2,017 | 2 | 1,427 | 3,446 |
| mkdocs/site/phil (Files) | 1 | 717 | 1 | 661 | 1,379 |
| mkdocs/site/phil/cost-comparison | 1 | 1,300 | 1 | 766 | 2,067 |
| mkdocs/site/search | 1 | 1 | 0 | 0 | 1 |
| mkdocs/site/services | 12 | 10,498 | 12 | 14,131 | 24,641 |
| mkdocs/site/services (Files) | 1 | 769 | 1 | 1,113 | 1,883 |
| mkdocs/site/services/code-server | 1 | 755 | 1 | 1,155 | 1,911 |
| mkdocs/site/services/gitea | 1 | 737 | 1 | 1,151 | 1,889 |
| mkdocs/site/services/homepage | 1 | 1,119 | 1 | 1,235 | 2,355 |
| mkdocs/site/services/listmonk | 1 | 775 | 1 | 1,159 | 1,935 |
| mkdocs/site/services/map | 1 | 877 | 1 | 1,170 | 2,048 |
| mkdocs/site/services/mini-qr | 1 | 707 | 1 | 1,147 | 1,855 |
| mkdocs/site/services/mkdocs | 1 | 905 | 1 | 1,187 | 2,093 |
| mkdocs/site/services/n8n | 1 | 1,057 | 1 | 1,223 | 2,281 |
| mkdocs/site/services/nocodb | 1 | 1,040 | 1 | 1,219 | 2,260 |
| mkdocs/site/services/postgresql | 1 | 880 | 1 | 1,190 | 2,071 |
| mkdocs/site/services/static-server | 1 | 877 | 1 | 1,182 | 2,060 |
| mkdocs/site/stylesheets | 2 | 1,364 | 72 | 235 | 1,671 |
| mkdocs/site/test | 1 | 278 | 1 | 484 | 763 |
Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)

View File

@ -0,0 +1,486 @@
Date : 2025-09-05 12:42:08
Directory : /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite
Total : 335 files, 91924 codes, 4969 comments, 47542 blanks, all 144435 lines
Languages
+--------------+------------+------------+------------+------------+------------+
| language | files | code | comment | blank | total |
+--------------+------------+------------+------------+------------+------------+
| HTML | 53 | 38,504 | 164 | 37,745 | 76,413 |
| JavaScript | 129 | 27,027 | 3,870 | 4,499 | 35,396 |
| PostCSS | 46 | 10,405 | 501 | 1,857 | 12,763 |
| Markdown | 50 | 6,210 | 5 | 2,406 | 8,621 |
| CSV | 7 | 2,913 | 0 | 409 | 3,322 |
| JSON | 30 | 2,634 | 0 | 5 | 2,639 |
| Shell Script | 4 | 2,480 | 342 | 488 | 3,310 |
| Log | 2 | 705 | 0 | 17 | 722 |
| YAML | 9 | 608 | 25 | 65 | 698 |
| Python | 2 | 238 | 48 | 32 | 318 |
| XML | 1 | 147 | 0 | 0 | 147 |
| Properties | 1 | 33 | 7 | 9 | 49 |
| Docker | 1 | 20 | 7 | 10 | 37 |
+--------------+------------+------------+------------+------------+------------+
Directories
+---------------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| path | files | code | comment | blank | total |
+---------------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| . | 335 | 91,924 | 4,969 | 47,542 | 144,435 |
| . (Files) | 7 | 1,862 | 284 | 435 | 2,581 |
| configs | 10 | 897 | 19 | 63 | 979 |
| configs/homepage | 9 | 864 | 12 | 54 | 930 |
| configs/homepage (Files) | 8 | 172 | 12 | 40 | 224 |
| configs/homepage/logs | 1 | 692 | 0 | 14 | 706 |
| configs/mkdocs-site | 1 | 33 | 7 | 9 | 49 |
| map | 167 | 40,025 | 3,126 | 6,580 | 49,731 |
| map (Files) | 7 | 2,177 | 60 | 664 | 2,901 |
| map/app | 139 | 33,222 | 3,016 | 5,083 | 41,321 |
| map/app (Files) | 4 | 2,515 | 50 | 61 | 2,626 |
| map/app/config | 1 | 118 | 15 | 18 | 151 |
| map/app/controllers | 12 | 3,060 | 276 | 548 | 3,884 |
| map/app/middleware | 2 | 255 | 23 | 34 | 312 |
| map/app/public | 86 | 24,382 | 2,278 | 3,910 | 30,570 |
| map/app/public (Files) | 6 | 2,131 | 93 | 280 | 2,504 |
| map/app/public/css | 40 | 7,726 | 357 | 1,399 | 9,482 |
| map/app/public/css (Files) | 6 | 1,474 | 62 | 288 | 1,824 |
| map/app/public/css/admin | 11 | 2,363 | 114 | 437 | 2,914 |
| map/app/public/css/modules | 23 | 3,889 | 181 | 674 | 4,744 |
| map/app/public/js | 40 | 14,525 | 1,828 | 2,231 | 18,584 |
| map/app/public/js (Files) | 33 | 14,525 | 1,828 | 2,224 | 18,577 |
| map/app/public/js/admin | 7 | 0 | 0 | 7 | 7 |
| map/app/routes | 17 | 767 | 120 | 188 | 1,075 |
| map/app/services | 8 | 1,189 | 176 | 230 | 1,595 |
| map/app/templates | 6 | 644 | 0 | 39 | 683 |
| map/app/templates/email | 6 | 644 | 0 | 39 | 683 |
| map/app/utils | 3 | 292 | 78 | 55 | 425 |
| map/data | 12 | 3,284 | 45 | 498 | 3,827 |
| map/instruct | 9 | 1,342 | 5 | 335 | 1,682 |
| mkdocs | 151 | 49,140 | 1,540 | 40,464 | 91,144 |
| mkdocs (Files) | 1 | 167 | 11 | 11 | 189 |
| mkdocs/docs | 54 | 7,637 | 151 | 2,040 | 9,828 |
| mkdocs/docs (Files) | 2 | 90 | 0 | 35 | 125 |
| mkdocs/docs/adv | 3 | 886 | 0 | 327 | 1,213 |
| mkdocs/docs/assets | 11 | 176 | 0 | 0 | 176 |
| mkdocs/docs/assets/repo-data | 11 | 176 | 0 | 0 | 176 |
| mkdocs/docs/blog | 4 | 70 | 0 | 29 | 99 |
| mkdocs/docs/blog (Files) | 1 | 0 | 0 | 1 | 1 |
| mkdocs/docs/blog/posts | 3 | 70 | 0 | 28 | 98 |
| mkdocs/docs/build | 4 | 687 | 0 | 273 | 960 |
| mkdocs/docs/config | 5 | 588 | 0 | 230 | 818 |
| mkdocs/docs/hooks | 1 | 119 | 24 | 16 | 159 |
| mkdocs/docs/how to | 1 | 2 | 0 | 3 | 5 |
| mkdocs/docs/javascripts | 3 | 536 | 43 | 59 | 638 |
| mkdocs/docs/manual | 2 | 93 | 0 | 49 | 142 |
| mkdocs/docs/overrides | 2 | 1,880 | 12 | 262 | 2,154 |
| mkdocs/docs/phil | 2 | 248 | 0 | 120 | 368 |
| mkdocs/docs/services | 12 | 898 | 0 | 402 | 1,300 |
| mkdocs/docs/stylesheets | 2 | 1,364 | 72 | 235 | 1,671 |
| mkdocs/site | 96 | 41,336 | 1,378 | 38,413 | 81,127 |
| mkdocs/site (Files) | 3 | 2,270 | 12 | 696 | 2,978 |
| mkdocs/site/adv | 3 | 4,128 | 3 | 3,767 | 7,898 |
| mkdocs/site/adv (Files) | 1 | 549 | 1 | 1,083 | 1,633 |
| mkdocs/site/adv/ansible | 1 | 1,653 | 1 | 1,326 | 2,980 |
| mkdocs/site/adv/vscode-ssh | 1 | 1,926 | 1 | 1,358 | 3,285 |
| mkdocs/site/assets | 49 | 5,320 | 1,180 | 939 | 7,439 |
| mkdocs/site/assets/javascripts | 36 | 5,142 | 1,180 | 939 | 7,261 |
| mkdocs/site/assets/javascripts (Files) | 1 | 14 | 1 | 2 | 17 |
| mkdocs/site/assets/javascripts/lunr | 34 | 5,090 | 1,176 | 935 | 7,201 |
| mkdocs/site/assets/javascripts/lunr (Files) | 2 | 5,058 | 936 | 920 | 6,914 |
| mkdocs/site/assets/javascripts/lunr/min | 32 | 32 | 240 | 15 | 287 |
| mkdocs/site/assets/javascripts/workers | 1 | 38 | 3 | 2 | 43 |
| mkdocs/site/assets/repo-data | 11 | 176 | 0 | 0 | 176 |
| mkdocs/site/assets/stylesheets | 2 | 2 | 0 | 0 | 2 |
| mkdocs/site/blog | 5 | 2,114 | 5 | 2,909 | 5,028 |
| mkdocs/site/blog (Files) | 1 | 451 | 1 | 572 | 1,024 |
| mkdocs/site/blog/2025 | 3 | 1,221 | 3 | 1,755 | 2,979 |
| mkdocs/site/blog/2025/07 | 2 | 766 | 2 | 1,165 | 1,933 |
| mkdocs/site/blog/2025/07/03 | 1 | 374 | 1 | 582 | 957 |
| mkdocs/site/blog/2025/07/03/blog-1 | 1 | 374 | 1 | 582 | 957 |
| mkdocs/site/blog/2025/07/10 | 1 | 392 | 1 | 583 | 976 |
| mkdocs/site/blog/2025/07/10/2 | 1 | 392 | 1 | 583 | 976 |
| mkdocs/site/blog/2025/08 | 1 | 455 | 1 | 590 | 1,046 |
| mkdocs/site/blog/2025/08/01 | 1 | 455 | 1 | 590 | 1,046 |
| mkdocs/site/blog/2025/08/01/3 | 1 | 455 | 1 | 590 | 1,046 |
| mkdocs/site/blog/archive | 1 | 442 | 1 | 582 | 1,025 |
| mkdocs/site/blog/archive/2025 | 1 | 442 | 1 | 582 | 1,025 |
| mkdocs/site/build | 4 | 4,088 | 4 | 4,753 | 8,845 |
| mkdocs/site/build (Files) | 1 | 1,312 | 1 | 1,199 | 2,512 |
| mkdocs/site/build/map | 1 | 983 | 1 | 1,182 | 2,166 |
| mkdocs/site/build/server | 1 | 1,045 | 1 | 1,234 | 2,280 |
| mkdocs/site/build/site | 1 | 748 | 1 | 1,138 | 1,887 |
| mkdocs/site/config | 5 | 4,912 | 5 | 5,966 | 10,883 |
| mkdocs/site/config (Files) | 1 | 550 | 1 | 1,083 | 1,634 |
| mkdocs/site/config/cloudflare-config | 1 | 740 | 1 | 1,146 | 1,887 |
| mkdocs/site/config/coder | 1 | 1,117 | 1 | 1,240 | 2,358 |
| mkdocs/site/config/map | 1 | 1,672 | 1 | 1,346 | 3,019 |
| mkdocs/site/config/mkdocs | 1 | 833 | 1 | 1,151 | 1,985 |
| mkdocs/site/hooks | 1 | 119 | 24 | 16 | 159 |
| mkdocs/site/how to | 1 | 275 | 1 | 484 | 760 |
| mkdocs/site/how to/canvass | 1 | 275 | 1 | 484 | 760 |
| mkdocs/site/javascripts | 3 | 536 | 43 | 59 | 638 |
| mkdocs/site/manual | 2 | 1,536 | 2 | 2,285 | 3,823 |
| mkdocs/site/manual (Files) | 1 | 549 | 1 | 1,083 | 1,633 |
| mkdocs/site/manual/map | 1 | 987 | 1 | 1,202 | 2,190 |
| mkdocs/site/overrides | 2 | 1,880 | 12 | 262 | 2,154 |
| mkdocs/site/phil | 2 | 2,017 | 2 | 1,427 | 3,446 |
| mkdocs/site/phil (Files) | 1 | 717 | 1 | 661 | 1,379 |
| mkdocs/site/phil/cost-comparison | 1 | 1,300 | 1 | 766 | 2,067 |
| mkdocs/site/search | 1 | 1 | 0 | 0 | 1 |
| mkdocs/site/services | 12 | 10,498 | 12 | 14,131 | 24,641 |
| mkdocs/site/services (Files) | 1 | 769 | 1 | 1,113 | 1,883 |
| mkdocs/site/services/code-server | 1 | 755 | 1 | 1,155 | 1,911 |
| mkdocs/site/services/gitea | 1 | 737 | 1 | 1,151 | 1,889 |
| mkdocs/site/services/homepage | 1 | 1,119 | 1 | 1,235 | 2,355 |
| mkdocs/site/services/listmonk | 1 | 775 | 1 | 1,159 | 1,935 |
| mkdocs/site/services/map | 1 | 877 | 1 | 1,170 | 2,048 |
| mkdocs/site/services/mini-qr | 1 | 707 | 1 | 1,147 | 1,855 |
| mkdocs/site/services/mkdocs | 1 | 905 | 1 | 1,187 | 2,093 |
| mkdocs/site/services/n8n | 1 | 1,057 | 1 | 1,223 | 2,281 |
| mkdocs/site/services/nocodb | 1 | 1,040 | 1 | 1,219 | 2,260 |
| mkdocs/site/services/postgresql | 1 | 880 | 1 | 1,190 | 2,071 |
| mkdocs/site/services/static-server | 1 | 877 | 1 | 1,182 | 2,060 |
| mkdocs/site/stylesheets | 2 | 1,364 | 72 | 235 | 1,671 |
| mkdocs/site/test | 1 | 278 | 1 | 484 | 763 |
+---------------------------------------------------------------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
Files
+---------------------------------------------------------------------------------------------------------------------------------------------------+--------------+------------+------------+------------+------------+
| filename | language | code | comment | blank | total |
+---------------------------------------------------------------------------------------------------------------------------------------------------+--------------+------------+------------+------------+------------+
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/README.md | Markdown | 55 | 0 | 24 | 79 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/combined.log | Log | 13 | 0 | 3 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/config.sh | Shell Script | 810 | 177 | 224 | 1,211 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/bookmarks.yaml | YAML | 47 | 2 | 5 | 54 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/custom.css | PostCSS | 0 | 0 | 1 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/custom.js | JavaScript | 0 | 0 | 1 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/docker.yaml | YAML | 3 | 2 | 2 | 7 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/kubernetes.yaml | YAML | 1 | 1 | 1 | 3 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/logs/homepage.log | Log | 692 | 0 | 14 | 706 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/services.yaml | YAML | 59 | 1 | 15 | 75 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/settings.yaml | YAML | 36 | 4 | 10 | 50 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/homepage/widgets.yaml | YAML | 26 | 2 | 5 | 33 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/mkdocs-site/default.conf | Properties | 33 | 7 | 9 | 49 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/docker-compose.yml | YAML | 238 | 2 | 13 | 253 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/Instuctions.md | Markdown | 56 | 0 | 21 | 77 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/README.md | Markdown | 851 | 0 | 252 | 1,103 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/Dockerfile | Docker | 20 | 7 | 10 | 37 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/config/index.js | JavaScript | 118 | 15 | 18 | 151 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/authController.js | JavaScript | 172 | 13 | 25 | 210 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/cutsController.js | JavaScript | 276 | 41 | 47 | 364 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/dashboardController.js | JavaScript | 70 | 9 | 15 | 94 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/dataConvertController.js | JavaScript | 352 | 50 | 76 | 478 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/externalDataController.js | JavaScript | 101 | 11 | 21 | 133 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/listmonkController.js | JavaScript | 212 | 12 | 29 | 253 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/locationsController.js | JavaScript | 333 | 23 | 58 | 414 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/passwordRecoveryController.js | JavaScript | 45 | 4 | 12 | 61 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/publicShiftsController.js | JavaScript | 212 | 21 | 38 | 271 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/settingsController.js | JavaScript | 303 | 17 | 50 | 370 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/shiftsController.js | JavaScript | 643 | 57 | 123 | 823 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/controllers/usersController.js | JavaScript | 341 | 18 | 54 | 413 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/middleware/auth.js | JavaScript | 170 | 13 | 25 | 208 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/middleware/rateLimiter.js | JavaScript | 85 | 10 | 9 | 104 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/package-lock.json | JSON | 2,203 | 0 | 1 | 2,204 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/package.json | JSON | 43 | 0 | 1 | 44 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/admin.html | HTML | 1,080 | 46 | 135 | 1,261 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/REFACTORING_SUMMARY.md | Markdown | 51 | 0 | 13 | 64 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin.css | PostCSS | 12 | 4 | 3 | 19 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/cuts-shifts.css | PostCSS | 225 | 13 | 44 | 282 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/data-convert.css | PostCSS | 176 | 7 | 33 | 216 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/forms.css | PostCSS | 207 | 9 | 36 | 252 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/layout.css | PostCSS | 202 | 7 | 36 | 245 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/modals.css | PostCSS | 259 | 6 | 46 | 311 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/nocodb-links.css | PostCSS | 109 | 4 | 23 | 136 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/responsive.css | PostCSS | 552 | 28 | 112 | 692 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/status-messages.css | PostCSS | 154 | 9 | 27 | 190 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/user-management.css | PostCSS | 179 | 10 | 31 | 220 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/variables.css | PostCSS | 48 | 9 | 10 | 67 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/admin/walk-sheet.css | PostCSS | 252 | 12 | 39 | 303 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/apartment-marker.css | PostCSS | 35 | 2 | 5 | 42 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/apartment-popup.css | PostCSS | 197 | 9 | 30 | 236 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/base.css | PostCSS | 68 | 22 | 12 | 102 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/buttons.css | PostCSS | 70 | 2 | 16 | 88 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/cache-busting.css | PostCSS | 99 | 2 | 13 | 114 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/cuts.css | PostCSS | 829 | 22 | 146 | 997 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/dashboard.css | PostCSS | 129 | 3 | 29 | 161 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/doc-search.css | PostCSS | 123 | 2 | 20 | 145 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/forms.css | PostCSS | 105 | 2 | 18 | 125 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/layout.css | PostCSS | 83 | 5 | 11 | 99 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/leaflet-custom.css | PostCSS | 147 | 20 | 32 | 199 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/listmonk.css | PostCSS | 295 | 4 | 55 | 354 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/map-controls.css | PostCSS | 281 | 13 | 45 | 339 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/mobile-ui.css | PostCSS | 205 | 8 | 36 | 249 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/modal.css | PostCSS | 73 | 1 | 10 | 84 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/nocodb-dropdown.css | PostCSS | 134 | 4 | 24 | 162 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/notifications.css | PostCSS | 105 | 5 | 16 | 126 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/print.css | PostCSS | 11 | 1 | 2 | 14 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/qr-code.css | PostCSS | 59 | 3 | 13 | 75 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/responsive.css | PostCSS | 150 | 12 | 29 | 191 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/start-location-marker.css | PostCSS | 65 | 3 | 8 | 76 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/temp-user.css | PostCSS | 46 | 6 | 5 | 57 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/modules/unified-search.css | PostCSS | 580 | 30 | 99 | 709 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/public-shifts.css | PostCSS | 418 | 24 | 83 | 525 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/shifts.css | PostCSS | 608 | 21 | 118 | 747 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/style.css | PostCSS | 21 | 0 | 1 | 22 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/css/user.css | PostCSS | 364 | 13 | 70 | 447 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/index.html | HTML | 384 | 28 | 45 | 457 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-auth.js | JavaScript | 63 | 11 | 14 | 88 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-core.js | JavaScript | 239 | 46 | 40 | 325 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-cuts.js | JavaScript | 1,505 | 184 | 302 | 1,991 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-email.js | JavaScript | 319 | 29 | 47 | 395 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-map.js | JavaScript | 178 | 26 | 46 | 250 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-shift-volunteers.js | JavaScript | 416 | 49 | 66 | 531 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-shifts.js | JavaScript | 330 | 35 | 55 | 420 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-users.js | JavaScript | 305 | 16 | 45 | 366 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin-walksheet.js | JavaScript | 387 | 35 | 50 | 472 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin.js | JavaScript | 2,309 | 178 | 288 | 2,775 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/auth.js | JavaScript | 0 | 0 | 1 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/navigation.js | JavaScript | 0 | 0 | 1 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/shifts.js | JavaScript | 0 | 0 | 1 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/startLocation.js | JavaScript | 0 | 0 | 1 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/users.js | JavaScript | 0 | 0 | 1 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/utils.js | JavaScript | 0 | 0 | 1 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/admin/walkSheet.js | JavaScript | 0 | 0 | 1 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/auth.js | JavaScript | 181 | 28 | 34 | 243 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/cache-manager.js | JavaScript | 219 | 71 | 28 | 318 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/config.js | JavaScript | 32 | 3 | 3 | 38 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/cut-controls.js | JavaScript | 982 | 163 | 145 | 1,290 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/cut-drawing.js | JavaScript | 206 | 72 | 59 | 337 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/cut-manager.js | JavaScript | 359 | 96 | 79 | 534 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/dashboard.js | JavaScript | 333 | 23 | 33 | 389 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/data-convert.js | JavaScript | 510 | 64 | 118 | 692 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/database-search.js | JavaScript | 186 | 76 | 51 | 313 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/external-layers.js | JavaScript | 513 | 39 | 45 | 597 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/listmonk-admin.js | JavaScript | 410 | 76 | 85 | 571 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/listmonk-status.js | JavaScript | 169 | 25 | 32 | 226 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/location-manager.js | JavaScript | 998 | 56 | 91 | 1,145 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/main.js | JavaScript | 178 | 40 | 36 | 254 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/map-manager.js | JavaScript | 82 | 12 | 19 | 113 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/map-search.js | JavaScript | 120 | 44 | 36 | 200 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/mkdocs-search.js | JavaScript | 263 | 36 | 52 | 351 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/public-shifts.js | JavaScript | 518 | 15 | 25 | 558 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/search-manager.js | JavaScript | 407 | 138 | 100 | 645 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/shifts.js | JavaScript | 1,059 | 35 | 55 | 1,149 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/ui-controls.js | JavaScript | 572 | 74 | 101 | 747 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/user.js | JavaScript | 81 | 12 | 19 | 112 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/js/utils.js | JavaScript | 96 | 21 | 25 | 142 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/login.html | HTML | 459 | 3 | 76 | 538 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/public-shifts.html | HTML | 88 | 4 | 7 | 99 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/shifts.html | HTML | 73 | 5 | 8 | 86 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/public/user.html | HTML | 47 | 7 | 9 | 63 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/admin.js | JavaScript | 80 | 15 | 16 | 111 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/auth.js | JavaScript | 14 | 5 | 6 | 25 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/cuts.js | JavaScript | 18 | 6 | 7 | 31 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/dashboard.js | JavaScript | 5 | 0 | 3 | 8 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/dataConvert.js | JavaScript | 21 | 4 | 6 | 31 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/debug.js | JavaScript | 236 | 10 | 36 | 282 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/external.js | JavaScript | 7 | 2 | 4 | 13 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/geocoding.js | JavaScript | 120 | 24 | 31 | 175 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/index.js | JavaScript | 153 | 29 | 36 | 218 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/listmonk.js | JavaScript | 12 | 5 | 9 | 26 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/locations.js | JavaScript | 11 | 5 | 6 | 22 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/public.js | JavaScript | 8 | 1 | 3 | 12 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/publicShifts.js | JavaScript | 8 | 1 | 2 | 11 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/qr.js | JavaScript | 39 | 1 | 8 | 48 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/settings.js | JavaScript | 8 | 2 | 3 | 13 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/shifts.js | JavaScript | 16 | 4 | 5 | 25 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/routes/users.js | JavaScript | 11 | 6 | 7 | 24 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/server.js | JavaScript | 249 | 43 | 49 | 341 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/accountExpiration.js | JavaScript | 59 | 11 | 19 | 89 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/email.js | JavaScript | 109 | 4 | 19 | 132 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/emailTemplates.js | JavaScript | 59 | 10 | 16 | 85 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/geocoding.js | JavaScript | 219 | 51 | 44 | 314 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/listmonk.js | JavaScript | 412 | 36 | 66 | 514 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/nocodb.js | JavaScript | 172 | 22 | 36 | 230 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/qrcode.js | JavaScript | 110 | 33 | 20 | 163 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/services/socrata.js | JavaScript | 49 | 9 | 10 | 68 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/login-details.html | HTML | 113 | 0 | 4 | 117 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/password-recovery.html | HTML | 79 | 0 | 1 | 80 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/public-shift-signup-existing.html | HTML | 161 | 0 | 22 | 183 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/public-shift-signup-new.html | HTML | 31 | 0 | 5 | 36 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/shift-details.html | HTML | 152 | 0 | 5 | 157 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/templates/email/user-broadcast.html | HTML | 108 | 0 | 2 | 110 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/utils/cacheBusting.js | JavaScript | 105 | 51 | 24 | 180 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/utils/helpers.js | JavaScript | 149 | 25 | 28 | 202 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/app/utils/logger.js | JavaScript | 38 | 2 | 3 | 43 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/build-nocodb.sh | Shell Script | 925 | 60 | 93 | 1,078 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City of Edmonton - Neighbourhoods (Centroid Point)_20250807.geojson | JSON | 3 | 0 | 1 | 4 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City of Edmonton - Neighbourhoods_20250807.geojson | JSON | 5 | 0 | 0 | 5 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City of Edmonton Ward Boundary and Council Composition_Current_20250807.geojson | JSON | 5 | 0 | 0 | 5 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City_of_Edmonton_-_Neighbourhoods_20250807.csv | CSV | 689 | 0 | 1 | 690 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City_of_Edmonton_-_Neighbourhoods__Centroid_Point__20250807.csv | CSV | 407 | 0 | 1 | 408 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City_of_Edmonton_Ward_Boundary_and_Council_Composition_Current_20250807.csv | CSV | 13 | 0 | 1 | 14 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/convert_edmonton_optimized.js | JavaScript | 185 | 21 | 43 | 249 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/convert_ward_boundaries_optimized.js | JavaScript | 173 | 24 | 45 | 242 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/edmonton_neighborhoods_nocodb_optimized.csv | CSV | 689 | 0 | 0 | 689 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/edmonton_nocodb_optimized.csv | CSV | 1,095 | 0 | 406 | 1,501 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/edmonton_ward_boundaries_optimized.csv | CSV | 13 | 0 | 0 | 13 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/exampledata.csv | CSV | 7 | 0 | 0 | 7 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/docker-compose.yml | YAML | 31 | 0 | 3 | 34 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/files-explainer.md | Markdown | 292 | 0 | 293 | 585 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/ADMIN_IMPLEMENTATION.md | Markdown | 102 | 0 | 28 | 130 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/CUT_IMPLEMENTATION_SUMMARY.md | Markdown | 156 | 0 | 34 | 190 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/CUT_PUBLIC_IMPLEMENTATION.md | Markdown | 124 | 5 | 31 | 160 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/CUT_SIMPLIFICATION_SUMMARY.md | Markdown | 65 | 0 | 21 | 86 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/LISTMONK_INTEGRATION_GUIDE.md | Markdown | 249 | 0 | 70 | 319 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/SHIFT_PERFORMANCE_FIX.md | Markdown | 71 | 0 | 22 | 93 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/TEMP_USER_IMPLEMENTATION.md | Markdown | 88 | 0 | 28 | 116 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/TEMP_USER_TEST.md | Markdown | 113 | 0 | 34 | 147 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/instruct/build-nocodb.md | Markdown | 374 | 0 | 67 | 441 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/package-lock.json | JSON | 17 | 0 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/package.json | JSON | 5 | 0 | 1 | 6 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/adv/ansible.md | Markdown | 373 | 0 | 152 | 525 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/adv/index.md | Markdown | 2 | 0 | 1 | 3 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/adv/vscode-ssh.md | Markdown | 511 | 0 | 174 | 685 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/anthropics-claude-code.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/coder-code-server.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/gethomepage-homepage.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/go-gitea-gitea.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/knadh-listmonk.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/lyqht-mini-qr.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/n8n-io-n8n.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/nocodb-nocodb.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/ollama-ollama.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/assets/repo-data/squidfunk-mkdocs-material.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/blog/index.md | Markdown | 0 | 0 | 1 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/blog/posts/1.md | Markdown | 6 | 0 | 3 | 9 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/blog/posts/2.md | Markdown | 10 | 0 | 4 | 14 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/blog/posts/3.md | Markdown | 54 | 0 | 21 | 75 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/build/index.md | Markdown | 336 | 0 | 150 | 486 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/build/map.md | Markdown | 155 | 0 | 62 | 217 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/build/server.md | Markdown | 122 | 0 | 27 | 149 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/build/site.md | Markdown | 74 | 0 | 34 | 108 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/cloudflare-config.md | Markdown | 45 | 0 | 16 | 61 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/coder.md | Markdown | 139 | 0 | 77 | 216 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/index.md | Markdown | 3 | 0 | 4 | 7 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/map.md | Markdown | 299 | 0 | 91 | 390 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/config/mkdocs.md | Markdown | 102 | 0 | 42 | 144 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/hooks/repo_widget_hook.py | Python | 119 | 24 | 16 | 159 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/how to/canvass.md | Markdown | 2 | 0 | 3 | 5 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/index.md | Markdown | 85 | 0 | 29 | 114 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/javascripts/gitea-widget.js | JavaScript | 121 | 3 | 8 | 132 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/javascripts/github-widget.js | JavaScript | 144 | 10 | 14 | 168 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/javascripts/home.js | JavaScript | 271 | 30 | 37 | 338 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/manual/index.md | Markdown | 2 | 0 | 1 | 3 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/manual/map.md | Markdown | 91 | 0 | 48 | 139 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/overrides/lander.html | HTML | 1,872 | 11 | 259 | 2,142 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/overrides/main.html | HTML | 8 | 1 | 3 | 12 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/phil/cost-comparison.md | Markdown | 153 | 0 | 51 | 204 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/phil/index.md | Markdown | 95 | 0 | 69 | 164 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/code-server.md | Markdown | 41 | 0 | 21 | 62 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/gitea.md | Markdown | 39 | 0 | 19 | 58 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/homepage.md | Markdown | 146 | 0 | 66 | 212 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/index.md | Markdown | 101 | 0 | 17 | 118 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/listmonk.md | Markdown | 47 | 0 | 22 | 69 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/map.md | Markdown | 70 | 0 | 27 | 97 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/mini-qr.md | Markdown | 23 | 0 | 15 | 38 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/mkdocs.md | Markdown | 86 | 0 | 47 | 133 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/n8n.md | Markdown | 109 | 0 | 49 | 158 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/nocodb.md | Markdown | 108 | 0 | 55 | 163 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/postgresql.md | Markdown | 59 | 0 | 32 | 91 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/services/static-server.md | Markdown | 69 | 0 | 32 | 101 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/stylesheets/extra.css | PostCSS | 498 | 18 | 82 | 598 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/stylesheets/home.css | PostCSS | 866 | 54 | 153 | 1,073 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/docs/test.md | Markdown | 5 | 0 | 6 | 11 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/mkdocs.yml | YAML | 167 | 11 | 11 | 189 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/404.html | HTML | 251 | 1 | 437 | 689 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/adv/ansible/index.html | HTML | 1,653 | 1 | 1,326 | 2,980 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/adv/index.html | HTML | 549 | 1 | 1,083 | 1,633 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/adv/vscode-ssh/index.html | HTML | 1,926 | 1 | 1,358 | 3,285 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/bundle.50899def.min.js | JavaScript | 14 | 1 | 2 | 17 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ar.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.da.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.de.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.du.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.el.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.es.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.fi.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.fr.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.he.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.hi.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.hu.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.hy.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.it.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ja.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.jp.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.kn.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ko.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.multi.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.nl.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.no.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.pt.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ro.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ru.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.sa.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.sv.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.ta.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.te.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.th.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.tr.min.js | JavaScript | 1 | 16 | 1 | 18 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.vi.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/min/lunr.zh.min.js | JavaScript | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/tinyseg.js | JavaScript | 176 | 21 | 9 | 206 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/lunr/wordcut.js | JavaScript | 4,882 | 915 | 911 | 6,708 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/javascripts/workers/search.d50fe291.min.js | JavaScript | 38 | 3 | 2 | 43 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/admin-changemaker.lite.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/anthropics-claude-code.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/coder-code-server.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/gethomepage-homepage.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/go-gitea-gitea.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/knadh-listmonk.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/lyqht-mini-qr.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/n8n-io-n8n.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/nocodb-nocodb.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/ollama-ollama.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/repo-data/squidfunk-mkdocs-material.json | JSON | 16 | 0 | 0 | 16 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/stylesheets/main.7e37652d.min.css | PostCSS | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/assets/stylesheets/palette.06af60db.min.css | PostCSS | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/2025/07/03/blog-1/index.html | HTML | 374 | 1 | 582 | 957 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/2025/07/10/2/index.html | HTML | 392 | 1 | 583 | 976 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/2025/08/01/3/index.html | HTML | 455 | 1 | 590 | 1,046 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/archive/2025/index.html | HTML | 442 | 1 | 582 | 1,025 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/blog/index.html | HTML | 451 | 1 | 572 | 1,024 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/build/index.html | HTML | 1,312 | 1 | 1,199 | 2,512 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/build/map/index.html | HTML | 983 | 1 | 1,182 | 2,166 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/build/server/index.html | HTML | 1,045 | 1 | 1,234 | 2,280 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/build/site/index.html | HTML | 748 | 1 | 1,138 | 1,887 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/cloudflare-config/index.html | HTML | 740 | 1 | 1,146 | 1,887 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/coder/index.html | HTML | 1,117 | 1 | 1,240 | 2,358 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/index.html | HTML | 550 | 1 | 1,083 | 1,634 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/map/index.html | HTML | 1,672 | 1 | 1,346 | 3,019 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/config/mkdocs/index.html | HTML | 833 | 1 | 1,151 | 1,985 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/hooks/repo_widget_hook.py | Python | 119 | 24 | 16 | 159 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/how to/canvass/index.html | HTML | 275 | 1 | 484 | 760 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/index.html | HTML | 1,872 | 11 | 259 | 2,142 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/javascripts/gitea-widget.js | JavaScript | 121 | 3 | 8 | 132 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/javascripts/github-widget.js | JavaScript | 144 | 10 | 14 | 168 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/javascripts/home.js | JavaScript | 271 | 30 | 37 | 338 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/manual/index.html | HTML | 549 | 1 | 1,083 | 1,633 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/manual/map/index.html | HTML | 987 | 1 | 1,202 | 2,190 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/overrides/lander.html | HTML | 1,872 | 11 | 259 | 2,142 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/overrides/main.html | HTML | 8 | 1 | 3 | 12 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/phil/cost-comparison/index.html | HTML | 1,300 | 1 | 766 | 2,067 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/phil/index.html | HTML | 717 | 1 | 661 | 1,379 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/search/search_index.json | JSON | 1 | 0 | 0 | 1 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/code-server/index.html | HTML | 755 | 1 | 1,155 | 1,911 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/gitea/index.html | HTML | 737 | 1 | 1,151 | 1,889 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/homepage/index.html | HTML | 1,119 | 1 | 1,235 | 2,355 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/index.html | HTML | 769 | 1 | 1,113 | 1,883 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/listmonk/index.html | HTML | 775 | 1 | 1,159 | 1,935 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/map/index.html | HTML | 877 | 1 | 1,170 | 2,048 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/mini-qr/index.html | HTML | 707 | 1 | 1,147 | 1,855 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/mkdocs/index.html | HTML | 905 | 1 | 1,187 | 2,093 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/n8n/index.html | HTML | 1,057 | 1 | 1,223 | 2,281 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/nocodb/index.html | HTML | 1,040 | 1 | 1,219 | 2,260 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/postgresql/index.html | HTML | 880 | 1 | 1,190 | 2,071 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/services/static-server/index.html | HTML | 877 | 1 | 1,182 | 2,060 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/sitemap.xml | XML | 147 | 0 | 0 | 147 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/stylesheets/extra.css | PostCSS | 498 | 18 | 82 | 598 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/stylesheets/home.css | PostCSS | 866 | 54 | 153 | 1,073 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/mkdocs/site/test/index.html | HTML | 278 | 1 | 484 | 763 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/reset-site.sh | Shell Script | 258 | 28 | 73 | 359 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/start-production.sh | Shell Script | 487 | 77 | 98 | 662 |
| /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/test.md | Markdown | 1 | 0 | 0 | 1 |
| Total | | 91,924 | 4,969 | 47,542 | 144,435 |
+---------------------------------------------------------------------------------------------------------------------------------------------------+--------------+------------+------------+------------+------------+

10
.gitignore vendored
View File

@ -6,3 +6,13 @@
.env .env
.env* .env*
/configs/cloudflare/*.json
/configs/cloudflare/*.yaml
/configs/cloudflare/*.yml
.excalidraw
/.VSCodeCounter
/influence/app/public/uploads

View File

@ -2,6 +2,16 @@ FROM codercom/code-server:latest
USER root USER root
# Install Node.js 18+ and npm
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs
# Install Claude Code globally as root
RUN npm install -g @anthropic-ai/claude-code
# Install Ollama
RUN curl -fsSL https://ollama.com/install.sh | sh
# Install Python and dependencies # Install Python and dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
python3 \ python3 \

View File

@ -1,300 +0,0 @@
# Changemaker Lite Landing Page Development Plan
*UPDATED: Grid-Optimized Media Integration Strategy*
## Executive Summary
We've revolutionized the Changemaker Lite landing page with an Alibaba-style grid system that maximizes screen real estate while aggressively promoting our self-hosting message: **"This Site Runs On Changemaker Lite."** The page is now a living proof-of-concept that converts visitors through demonstration, not just explanation.
## 🚀 SELF-HOSTING HYPE STRATEGY
**Core Message:** "The Infrastructure That Runs Itself"
### Primary Positioning:
- **"🤯 This Site Runs On Changemaker Lite"** - Meta-badge visible throughout
- **Live Proof Points** - Show actual BNKops infrastructure running on the same hardware
- **Real Savings Data** - Our actual $13,800/year savings vs corporate SaaS
- **Zero Corporate Dependencies** - Emphasize complete digital liberation
### Proof-of-Concept Integration:
1. **Meta-Validation** - Every page element proves the platform works
2. **Behind-the-Scenes Access** - Video demos of actual running services
3. **Real Stats Display** - Live subscriber counts, uptime metrics, cost savings
4. **Transparent Operations** - Show the actual $200 hardware running everything
## 🎯 GRID SYSTEM ARCHITECTURE
**Maximum Screen Real Estate - Alibaba Style**
### Grid Layout Principles:
- **Tight Spacing** - 0.5rem base gaps for content density
- **Modular Cards** - Every element in contained, hover-interactive cards
- **Responsive Scaling** - 4-column desktop → 2-column tablet → 1-column mobile
- **Information Hierarchy** - Color-coded importance levels
### Section Structure:
1. **Hero Grid** - 2x2 layout: Main message + Video + Stats + Trust indicators
2. **Proof Grid** - 4-card layout showing live infrastructure
3. **Hardware Grid** - 3-option purchase matrix
4. **Services Grid** - Tight showcase of all 11 included services
## 📹 MEDIA INTEGRATION STRATEGY
### Video Content Requirements:
#### 1. HERO VIDEO: "See Changemaker Lite In Action" (3:00)
**Script Outline:**
```
[0:00-0:15] HOOK: "What you're looking at right now is running on $200 hardware"
[0:15-0:45] TERMINAL DEMO: docker compose ps, service URLs, real data
[0:45-1:30] DASHBOARD TOUR: Homepage overview, all 11 services working
[1:30-2:15] BACKEND ACCESS: NocoDB, Listmonk, email campaigns
[2:15-2:45] COST COMPARISON: Corporate bills vs our $50/month
[2:45-3:00] CTA: "Get the same setup for $200"
```
#### 2. BACKEND DEMO: "Homepage Dashboard Tour" (1:30)
**Script Outline:**
```
[0:00-0:20] Homepage overview - all services green status
[0:20-0:50] Click through key services (NocoDB, Listmonk, Gitea)
[0:50-1:10] Show actual data - newsletters, databases, code repos
[1:10-1:30] "This is what you get out of the box"
```
#### 3. SETUP DEMO: "30-Minute Time-lapse" (5:23)
**Script Outline:**
```
[0:00-0:30] Unboxing hardware, connection setup
[0:30-2:00] Initial boot, docker compose deployment
[2:00-3:30] Service configuration, domain setup
[3:30-4:30] Testing all services, sending first email
[4:30-5:23] Final dashboard tour, "You're live!"
```
#### 4. COST BREAKDOWN: "Corporate vs Changemaker" (2:00)
**Script Outline:**
```
[0:00-0:30] Show typical SaaS stack bills ($1,200/mo)
[0:30-1:00] Break down each service cost vs Changemaker equivalent
[1:00-1:30] Annual savings calculation ($13,800)
[1:30-2:00] "Money for organizing, not shareholders"
```
### Video Production Specs:
- **Resolution:** 1920x1080 minimum, 4K preferred for hero video
- **Format:** MP4 with H.264 encoding
- **Length:** Keep under 3 minutes for engagement
- **Captions:** Required for accessibility
- **Hosting:** Self-hosted on Changemaker Lite (of course!)
## 🎨 VISUAL DESIGN UPDATES
### Grid-Optimized Elements:
- **Micro-interactions** - Card hovers, button states, loading animations
- **Progressive Enhancement** - Mobile-first, desktop-enhanced
- **Performance Focus** - Lazy loading, optimized assets, minimal JS
- **Brand Consistency** - Trans flag colors throughout
### Component Library:
1. **Grid Cards** - Standardized containers with consistent padding
2. **Video Placeholders** - Interactive preview states with play buttons
3. **Stat Displays** - Animated counters with real data integration
4. **CTA Buttons** - Primary/secondary hierarchy with hover effects
## 💰 CONVERSION OPTIMIZATION
### Three-Path Strategy:
1. **Plug & Play ($200)** - Recommended, ships in 5 days
2. **DIY Install (Free)** - Technical users, community support
3. **Managed Hosting ($150/mo)** - Hands-off but still your infrastructure
### Conversion Elements:
- **Social Proof** - "Powers BNKops.com" trust indicators
- **Urgency** - "Ships in 5 days" immediacy
- **Risk Reduction** - "30-minute setup" simplicity promise
- **Value Demonstration** - Live cost savings calculator
## 🎭 CONTENT PERSONALITY
### Voice & Tone:
- **Confident Revolutionary** - "We're not just selling it - we're living it"
- **Technical Transparency** - Show the actual infrastructure
- **Community-Focused** - "Trans liberation tech"
- **Anti-Corporate** - "Zero surveillance" messaging
### Key Phrases:
- "The Infrastructure That Runs Itself"
- "What You're Looking At Right Now"
- "Money for organizing, not shareholders"
- "Your data, your power, your movement"
- "Digital liberation movement"
## 📊 SUCCESS METRICS
### Primary KPIs:
1. **Hardware Orders** - Track $200 package conversions
2. **Installation Attempts** - DIY guide engagement
3. **Video Completion Rates** - Media effectiveness
4. **Time on Page** - Grid layout engagement
### Secondary Metrics:
1. **Self-hosting Message Retention** - Post-visit surveys
2. **Social Shares** - Viral potential tracking
3. **Return Visits** - Community building indicator
4. **Email Signups** - Newsletter conversion funnel
## 🚧 TECHNICAL IMPLEMENTATION
### Completed Updates:
1. ✅ **Grid-optimized CSS** - Alibaba-style layout system
2. ✅ **Self-hosting badges** - Meta-validation throughout
3. ✅ **Grid HTML structure** - Maximum screen real estate utilization
4. ✅ **Video placeholder integration** - Ready for content production
### Next Phase:
1. 📹 **Video Production** - Execute scripted content strategy
2. 📊 **Analytics Setup** - Privacy-respecting conversion tracking
3. 🧪 **A/B Testing Framework** - Message optimization capability
4. 🚀 **Performance Optimization** - Mobile loading improvements
### Performance Targets:
- **Load Time:** <2 seconds on 3G
- **Mobile Score:** >90 Lighthouse
- **Accessibility:** AA WCAG compliance
- **SEO:** Structured data, meta optimization
## 🎬 VIDEO PRODUCTION ROADMAP
### Phase 1: Hero Content (Priority 1)
1. **"See Changemaker Lite In Action" (3:00)** - Main conversion driver
2. **"30-Minute Setup Time-lapse" (5:23)** - Risk reduction
### Phase 2: Proof Content (Priority 2)
3. **"Homepage Dashboard Tour" (1:30)** - Technical demonstration
4. **"Corporate vs Changemaker Costs" (2:00)** - Value proposition
### Phase 3: Advanced Content (Priority 3)
5. **"Behind the Scenes: How BNKops Runs" (4:00)** - Advanced proof
6. **"Community Testimonials" (2:30)** - Social proof compilation
### Production Requirements:
- **Screen Recording:** OBS Studio with 1080p60 capture
- **Hardware Demo:** Good lighting, stable camera setup
- **Audio:** Clear narration, background music optional
- **Editing:** Simple cuts, captions, brand consistency
- **Hosting:** Self-hosted on Changemaker Lite infrastructure
## 📈 CONVERSION FUNNEL OPTIMIZATION
### Landing Paths:
1. **Curious Visitor** → Video Demo → Understanding → Newsletter Signup
2. **Ready Buyer** → "Order Hardware" → Immediate conversion
3. **DIY Techie** → Installation Guide → Community Engagement
4. **Managed Solution** → Hosting Inquiry → Sales Process
### Optimization Priorities:
1. **Video Completion Rates** - Shorter, punchier content
2. **Hardware Order Flow** - Streamlined purchase process
3. **Newsletter Value** - Immediate PDF guides/resources
4. **Mobile Experience** - Touch-optimized interactions
## 🎯 IMMEDIATE ACTION ITEMS
### This Week:
1. **Video Script Finalization** - Get all 4 core scripts approved
2. **Screen Recording Setup** - Prepare clean demo environment
3. **Analytics Integration** - Privacy-respecting tracking setup
4. **Mobile Testing** - Cross-device grid layout verification
### Next Week:
1. **Hero Video Production** - Priority 1 content creation
2. **Setup Demo Recording** - Time-lapse capture
3. **Performance Audit** - Loading speed optimization
4. **A/B Testing Framework** - Message variation testing
### Month 1:
1. **Full Video Suite** - All 6 videos produced and integrated
2. **Conversion Optimization** - Data-driven improvements
3. **Community Feedback** - User testing and iteration
4. **SEO Enhancement** - Search visibility improvements
## 🏆 SUCCESS DEFINITION
### Quantitative Goals:
- **50% video completion rate** for hero video
- **15% conversion rate** from visitor to newsletter signup
- **10% hardware order rate** from engaged visitors
- **<2 second load time** on mobile devices
### Qualitative Goals:
- **"I get it now"** - Clear self-hosting value proposition
- **"This looks professional"** - Trust and credibility establishment
- **"I want this"** - Emotional connection to digital sovereignty
- **"It's actually simple"** - Technical barrier reduction
---
**THE LANDING PAGE IS NOW A LIVING PROOF THAT CHANGEMAKER LITE WORKS. EVERY VISITOR SEES EXACTLY WHAT THEY'LL GET - BECAUSE THEY'RE LOOKING AT IT.**
## 📹 FUTURE MEDIA INTEGRATION
### Video Content to Add Later:
#### 1. HERO VIDEO: "The FOSS Campaign Revolution" (2:30)
**Location:** Hero grid section
**Script Outline:**
```
[0:00-0:15] HOOK: "Stop paying $1,200/month for campaign tech"
[0:15-0:45] PROBLEM: Show corporate SaaS bills, vendor lock-in
[0:45-1:30] SOLUTION: Changemaker Lite demo - all services running
[1:30-2:00] PROOF: Real campaigns using it, cost savings
[2:00-2:30] CTA: "Join the digital liberation movement"
```
#### 2. SERVICE DEMOS: Individual Tool Showcases (0:30 each)
**Location:** Service cards in solution grid
- **Listmonk Demo:** Email campaign creation
- **NocoDB Demo:** Voter database management
- **Chatwoot Demo:** Text banking in action
- **n8n Demo:** Automation workflows
#### 3. COMPARISON VIDEO: "Corporate vs FOSS" (1:30)
**Location:** Comparison grid section
**Content:** Side-by-side comparison of costs, features, data ownership
#### 4. SETUP WALKTHROUGH: "From Box to Campaign" (5:00)
**Location:** Get started section
**Content:** Complete setup process time-lapse
### Screenshot Requirements:
- Dashboard overview showing all services
- Individual service interfaces
- Cost comparison charts
- Hardware photos (unboxing, setup)
- Real campaign examples (anonymized)
### Animation Enhancements:
- Service icon animations (already implemented)
- Interactive comparison table
- Live visitor counter
- Real-time savings calculator
## 🎯 COMPLETED UPDATES
### Page Focus:
✅ **Repositioned as FOSS political campaign solution**
✅ **Removed video placeholders for cleaner grid**
✅ **Added comparison with corporate alternatives**
✅ **Emphasized cost savings and data ownership**
### Design Implementation:
✅ **Ultra-tight grid system (Alibaba-style)**
✅ **Neon animations with trans flag colors**
✅ **Smooth scroll and parallax effects**
✅ **Responsive grid that reflows perfectly**
✅ **Stagger animations on scroll**
✅ **Interactive hover states**
### Content Strategy:
✅ **Clear problem/solution messaging**
✅ **Service-by-service breakdown**
✅ **Direct corporate alternative comparisons**
✅ **Three-tier pricing strategy**
✅ **Trust indicators and social proof**

View File

@ -12,9 +12,13 @@ Changemaker Lite is a streamlined documentation and development platform featuri
- **PostgreSQL**: Reliable database backend - **PostgreSQL**: Reliable database backend
- **n8n**: Workflow automation and service integration - **n8n**: Workflow automation and service integration
- **NocoDB**: No-code database platform and smart spreadsheet interface - **NocoDB**: No-code database platform and smart spreadsheet interface
- **Map**: Interactive map visualization for geographic data with real-time geolocation, walk sheet generation, and QR code integration
- **Influence**: Campaign tool for connecting Alberta residents with elected representatives at all government levels
## Quick Start ## Quick Start
The whole system can be set up in minutes using Docker Compose. It is recommended to run this on a server with at least 8GB of RAM and 4 CPU cores for optimal performance. Instructions to build to production are available in the mkdocs/docs/build directory, at cmlite.org, or in the site preview.
```bash ```bash
# Clone the repository # Clone the repository
git clone https://gitea.bnkops.com/admin/changemaker.lite git clone https://gitea.bnkops.com/admin/changemaker.lite
@ -27,6 +31,39 @@ cd changemaker.lite
docker compose up -d docker compose up -d
``` ```
## Map
Instructions on how to build the map are available in the map directory.
Instructions on how to build for production are available in the mkdocs/docs/build directory or in the site preview.
### Quick Start for Map
Update the .env file in the map directory with your NocoDB URLs, and then run:
```bash
cd map
docker compose up -d
```
## Influence
The Influence Campaign Tool helps Alberta residents connect with elected representatives at federal, provincial, and municipal levels. Users can look up representatives by postal code and send advocacy emails through customizable campaigns.
Detailed setup and configuration instructions are available in the `influence/README.MD` file.
### Quick Start for Influence
Configure your environment and start the service:
```bash
cd influence
cp example.env .env
# Edit .env with your NocoDB and SMTP settings
./scripts/build-nocodb.sh # Set up database tables
docker compose up -d
```
## Service Access ## Service Access
After starting, access services at: After starting, access services at:
@ -38,6 +75,16 @@ After starting, access services at:
- **Listmonk**: http://localhost:9000 - **Listmonk**: http://localhost:9000
- **n8n**: http://localhost:5678 - **n8n**: http://localhost:5678
- **NocoDB**: http://localhost:8090 - **NocoDB**: http://localhost:8090
- **Map Viewer**: http://localhost:3000
- **Influence Campaign Tool**: http://localhost:3333
## Production Deployment
If you are deploying to production, using Cloudflare, you can use the included 'start-production.sh' script to set up a secure deployment with HTTPS. Ensure your domain and cloudflare settings are correctly configured in the root .env before running. More information on the required API tokens and settings can be found in the mkdocs/docs/build directory or at cmlite.org.
```bash
./start-production.sh
```
## Documentation ## Documentation
@ -46,6 +93,17 @@ Complete documentation is available in the MkDocs site, including:
- Service configuration guides - Service configuration guides
- Integration examples - Integration examples
- Workflow automation tutorials - Workflow automation tutorials
- Map application setup and usage
- Troubleshooting guides - Troubleshooting guides
Visit http://localhost:4000 after starting services to access the full documentation. Visit http://localhost:4000 after starting services to access the full documentation.
## Licensing
This project is licensed under the Apache License 2.0 - https://opensource.org/license/apache-2-0
## AI Disclaimer
This project used AI tools to assist in its creation and large amounts of the boilerplate code was reviewed using AI. AI tools (although not activated or connected) are pre-installed in the Coder docker image. See `docker.code-server` for more details.
While these tools can help generate code and documentation, they may also introduce errors or inaccuracies. Users should review and test all content to ensure it meets their requirements and standards.

View File

@ -1,290 +0,0 @@
#!/bin/bash
echo "#############################################################"
echo "# "
echo "# DNS Setup for Changemaker.lite Services "
echo "# "
echo "# This script will ADD DNS records for your services. "
echo "# Existing DNS records will NOT be deleted. "
echo "# "
echo "#############################################################"
echo ""
echo "-------------------------------------------------------------"
echo "Cloudflare Credentials Required"
echo "Please ensure your .env file contains the following variables:"
echo " CF_API_TOKEN=your_cloudflare_api_token"
echo " CF_ZONE_ID=your_cloudflare_zone_id"
echo " CF_TUNNEL_ID=your_cloudflared_tunnel_id"
echo " CF_DOMAIN=yourdomain.com"
echo ""
echo "You can find these values in your Cloudflare dashboard:"
echo " - API Token: https://dash.cloudflare.com/profile/api-tokens (Create a token with Zone:DNS:Edit and Access:Apps:Edit permissions for your domain)"
echo " - Zone ID: On your domain's overview page"
echo " - Tunnel ID: In the Zero Trust dashboard under Access > Tunnels"
echo " - Domain: The domain you want to use for your services"
echo ""
echo "-------------------------------------------------------------"
echo ""
read -p "Type 'y' to continue or any other key to abort: " consent
if [[ "$consent" != "y" && "$consent" != "Y" ]]; then
echo "Aborted by user."
exit 1
fi
# Source environment variables from the .env file in the same directory
ENV_FILE="$(dirname "$0")/.env"
if [ -f "$ENV_FILE" ]; then
export $(grep -v '^#' "$ENV_FILE" | xargs)
else
echo "Error: .env file not found at $ENV_FILE"
exit 1
fi
# Check if required Cloudflare variables are set
if [ -z "$CF_API_TOKEN" ] || [ -z "$CF_ZONE_ID" ] || [ -z "$CF_TUNNEL_ID" ] || [ -z "$CF_DOMAIN" ]; then
echo "Error: One or more required Cloudflare environment variables (CF_API_TOKEN, CF_ZONE_ID, CF_TUNNEL_ID, CF_DOMAIN) are not set in $ENV_FILE."
exit 1
fi
# Check if jq is installed
if ! command -v jq &> /dev/null; then
echo "Error: jq is required but not installed. Please install jq to continue."
echo "On Debian/Ubuntu: sudo apt-get install jq"
echo "On RHEL/CentOS: sudo yum install jq"
exit 1
fi
# Array of subdomains that need DNS records - updated to match our active services
SUBDOMAINS=(
"dashboard"
"code"
"listmonk"
"docs"
"n8n"
"db"
"git"
)
# Function to check if DNS record already exists
record_exists() {
local subdomain=$1
local records=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records?name=$subdomain.$CF_DOMAIN" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json")
local count=$(echo $records | jq -r '.result | length')
[ "$count" -gt 0 ]
}
# Add CNAME records for each subdomain (only if they don't exist)
echo "Adding DNS records for services..."
for subdomain in "${SUBDOMAINS[@]}"; do
if record_exists "$subdomain"; then
echo "DNS record for $subdomain.$CF_DOMAIN already exists, skipping..."
else
echo "Adding CNAME record for $subdomain.$CF_DOMAIN..."
response=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "{
\"type\": \"CNAME\",
\"name\": \"$subdomain\",
\"content\": \"$CF_TUNNEL_ID.cfargotunnel.com\",
\"ttl\": 1,
\"proxied\": true
}")
success=$(echo $response | jq -r '.success')
if [ "$success" == "true" ]; then
echo "✓ Successfully added CNAME record for $subdomain.$CF_DOMAIN"
else
echo "✗ Failed to add CNAME record for $subdomain.$CF_DOMAIN"
echo "Error: $(echo $response | jq -r '.errors[0].message')"
fi
fi
done
# Add root domain record if it doesn't exist
if record_exists "@"; then
echo "Root domain DNS record already exists, skipping..."
else
echo "Adding root domain CNAME record..."
response=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "{
\"type\": \"CNAME\",
\"name\": \"@\",
\"content\": \"$CF_TUNNEL_ID.cfargotunnel.com\",
\"ttl\": 1,
\"proxied\": true
}")
success=$(echo $response | jq -r '.success')
if [ "$success" == "true" ]; then
echo "✓ Successfully added root domain CNAME record"
else
echo "✗ Failed to add root domain CNAME record"
echo "Error: $(echo $response | jq -r '.errors[0].message')"
fi
fi
echo ""
echo "DNS records setup complete!"
echo ""
# Prompt for admin email for secured services
echo "-------------------------------------------------------------"
echo "Setting up Cloudflare Access Protection"
echo "-------------------------------------------------------------"
echo ""
echo "The following services will be protected with authentication:"
echo " - dashboard.$CF_DOMAIN"
echo " - code.$CF_DOMAIN"
echo ""
echo "Please enter the admin email address that should have access:"
read ADMIN_EMAIL
# Validate email format
if [[ ! "$ADMIN_EMAIL" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "Error: Invalid email format. Please provide a valid email address."
exit 1
fi
# Services that require authentication - updated for our use case
PROTECTED_SERVICES=("dashboard" "code")
# Services that should have bypass policies (public access) - updated for our use case
BYPASS_SERVICES=("listmonk" "docs" "n8n" "db" "git")
# Function to create access application with email authentication
create_protected_app() {
local service=$1
echo "Setting up authentication for $service.$CF_DOMAIN..."
app_response=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/access/apps" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "{
\"name\": \"$service $CF_DOMAIN\",
\"domain\": \"$service.$CF_DOMAIN\",
\"type\": \"self_hosted\",
\"session_duration\": \"24h\",
\"app_launcher_visible\": true,
\"skip_interstitial\": true
}")
app_id=$(echo $app_response | jq -r '.result.id')
if [ -z "$app_id" ] || [ "$app_id" == "null" ]; then
echo "✗ Error creating access application for $service"
return 1
fi
echo "✓ Created access application for $service (ID: $app_id)"
# Create authentication policy
policy_response=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/access/apps/$app_id/policies" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "{
\"name\": \"Admin Access\",
\"decision\": \"allow\",
\"include\": [{
\"email\": {
\"email\": \"$ADMIN_EMAIL\"
}
}],
\"require\": [],
\"exclude\": []
}")
policy_success=$(echo $policy_response | jq -r '.success')
if [ "$policy_success" == "true" ]; then
echo "✓ Created authentication policy for $service"
else
echo "✗ Failed to create authentication policy for $service"
fi
}
# Function to create bypass application (public access)
create_bypass_app() {
local service=$1
echo "Setting up public access for $service.$CF_DOMAIN..."
app_response=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/access/apps" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "{
\"name\": \"$service $CF_DOMAIN\",
\"domain\": \"$service.$CF_DOMAIN\",
\"type\": \"self_hosted\",
\"session_duration\": \"24h\",
\"app_launcher_visible\": false,
\"skip_interstitial\": true
}")
app_id=$(echo $app_response | jq -r '.result.id')
if [ -z "$app_id" ] || [ "$app_id" == "null" ]; then
echo "✗ Error creating access application for $service"
return 1
fi
echo "✓ Created access application for $service (ID: $app_id)"
# Create bypass policy
policy_response=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/access/apps/$app_id/policies" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "{
\"name\": \"Public Access\",
\"decision\": \"bypass\",
\"include\": [{
\"everyone\": {}
}],
\"require\": [],
\"exclude\": []
}")
policy_success=$(echo $policy_response | jq -r '.success')
if [ "$policy_success" == "true" ]; then
echo "✓ Created public access policy for $service"
else
echo "✗ Failed to create public access policy for $service"
fi
}
echo "Creating Cloudflare Access applications..."
echo ""
# Create protected applications
for service in "${PROTECTED_SERVICES[@]}"; do
create_protected_app "$service"
echo ""
done
# Create bypass applications for public services
for service in "${BYPASS_SERVICES[@]}"; do
create_bypass_app "$service"
echo ""
done
echo "-------------------------------------------------------------"
echo "Setup Complete!"
echo "-------------------------------------------------------------"
echo ""
echo "Protected services (require authentication with $ADMIN_EMAIL):"
for service in "${PROTECTED_SERVICES[@]}"; do
echo " - https://$service.$CF_DOMAIN"
done
echo ""
echo "Public services (no authentication required):"
for service in "${BYPASS_SERVICES[@]}"; do
echo " - https://$service.$CF_DOMAIN"
done
echo ""
echo "All services should be accessible through your Cloudflare tunnel."

15
combined.log Normal file
View File

@ -0,0 +1,15 @@
nohup: ignoring input
node:internal/modules/cjs/loader:1137
throw err;
^
Error: Cannot find module '/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/app/server.js'
at Module._resolveFilename (node:internal/modules/cjs/loader:1134:15)
at Module._load (node:internal/modules/cjs/loader:975:27)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:128:12)
at node:internal/main/run_main_module:28:49 {
code: 'MODULE_NOT_FOUND',
requireStack: []
}
Node.js v18.19.1

551
config.sh
View File

@ -26,6 +26,7 @@ TUNNEL_CONFIG_FILE="$TUNNEL_CONFIG_DIR/tunnel-config.yml"
SERVICES_YAML="$SCRIPT_DIR/configs/homepage/services.yaml" SERVICES_YAML="$SCRIPT_DIR/configs/homepage/services.yaml"
MAIN_HTML="$SCRIPT_DIR/mkdocs/docs/overrides/main.html" MAIN_HTML="$SCRIPT_DIR/mkdocs/docs/overrides/main.html"
MAP_ENV_FILE="$SCRIPT_DIR/map/.env" # Add the map's .env file path MAP_ENV_FILE="$SCRIPT_DIR/map/.env" # Add the map's .env file path
DOCKER_COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml"
echo "Looking for .env file at: $ENV_FILE" echo "Looking for .env file at: $ENV_FILE"
@ -182,7 +183,11 @@ initialize_available_ports() {
["GITEA_WEB_PORT"]=3030 ["GITEA_WEB_PORT"]=3030
["GITEA_SSH_PORT"]=2222 ["GITEA_SSH_PORT"]=2222
["MAP_PORT"]=3000 ["MAP_PORT"]=3000
["INFLUENCE_PORT"]=3333
["MINI_QR_PORT"]=8089 ["MINI_QR_PORT"]=8089
["REDIS_PORT"]=6379
["PROMETHEUS_PORT"]=9090
["GRAFANA_PORT"]=3001
) )
# Find available ports for each service # Find available ports for each service
@ -247,8 +252,14 @@ HOMEPAGE_PORT=${HOMEPAGE_PORT:-3010}
GITEA_WEB_PORT=${GITEA_WEB_PORT:-3030} GITEA_WEB_PORT=${GITEA_WEB_PORT:-3030}
GITEA_SSH_PORT=${GITEA_SSH_PORT:-2222} GITEA_SSH_PORT=${GITEA_SSH_PORT:-2222}
MAP_PORT=${MAP_PORT:-3000} MAP_PORT=${MAP_PORT:-3000}
INFLUENCE_PORT=${INFLUENCE_PORT:-3333}
MINI_QR_PORT=${MINI_QR_PORT:-8089} MINI_QR_PORT=${MINI_QR_PORT:-8089}
# Centralized Services Ports
REDIS_PORT=${REDIS_PORT:-6379}
PROMETHEUS_PORT=${PROMETHEUS_PORT:-9090}
GRAFANA_PORT=${GRAFANA_PORT:-3001}
# Domain Configuration # Domain Configuration
BASE_DOMAIN=https://changeme.org BASE_DOMAIN=https://changeme.org
DOMAIN=changeme.org DOMAIN=changeme.org
@ -298,6 +309,21 @@ NOCODB_DB_PASSWORD=changeMe
# Gitea Database Configuration # Gitea Database Configuration
GITEA_DB_PASSWD=changeMe GITEA_DB_PASSWD=changeMe
GITEA_DB_ROOT_PASSWORD=changeMe GITEA_DB_ROOT_PASSWORD=changeMe
# Centralized Services Configuration
# Redis (used by all applications for caching, sessions, queues)
REDIS_HOST=redis-changemaker
REDIS_PORT=6379
REDIS_PASSWORD=
# Prometheus (metrics collection)
PROMETHEUS_PORT=9090
PROMETHEUS_RETENTION_TIME=30d
# Grafana (monitoring dashboards)
GRAFANA_PORT=3001
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=changeMe
EOL EOL
echo "New .env file created with conflict-free port assignments." echo "New .env file created with conflict-free port assignments."
@ -316,7 +342,13 @@ EOL
echo "Gitea Web: ${GITEA_WEB_PORT:-3030}" echo "Gitea Web: ${GITEA_WEB_PORT:-3030}"
echo "Gitea SSH: ${GITEA_SSH_PORT:-2222}" echo "Gitea SSH: ${GITEA_SSH_PORT:-2222}"
echo "Map: ${MAP_PORT:-3000}" echo "Map: ${MAP_PORT:-3000}"
echo "Influence: ${INFLUENCE_PORT:-3333}"
echo "Mini QR: ${MINI_QR_PORT:-8089}" echo "Mini QR: ${MINI_QR_PORT:-8089}"
echo ""
echo "=== Centralized Services ==="
echo "Redis: ${REDIS_PORT:-6379}"
echo "Prometheus: ${PROMETHEUS_PORT:-9090}"
echo "Grafana: ${GRAFANA_PORT:-3001}"
echo "================================" echo "================================"
} }
@ -366,25 +398,57 @@ update_services_yaml() {
cp "$SERVICES_YAML" "$backup_file" cp "$SERVICES_YAML" "$backup_file"
echo "Created backup of services.yaml at $backup_file" echo "Created backup of services.yaml at $backup_file"
# Update all domain references - handle the current domain (albertademocracytaskforce.org) # Define service name to subdomain mapping
# First, update any existing domain to the new domain # This approach is URL-agnostic - it doesn't matter what the current URLs are
sed -i "s|albertademocracytaskforce\.org|$new_domain|g" "$SERVICES_YAML" declare -A service_mappings=(
["Code Server"]="code.$new_domain"
["Listmonk"]="listmonk.$new_domain"
["NocoDB"]="db.$new_domain"
["Map Server"]="map.$new_domain"
["Influence"]="influence.$new_domain"
["Main Site"]="$new_domain"
["MkDocs (Live)"]="docs.$new_domain"
["Mini QR"]="qr.$new_domain"
["n8n"]="n8n.$new_domain"
["Gitea"]="git.$new_domain"
["Prometheus"]="prometheus.$new_domain"
["Grafana"]="grafana.$new_domain"
)
# Also update any changeme.org references that might exist # Process each service mapping
sed -i "s|changeme\.org|$new_domain|g" "$SERVICES_YAML" for service_name in "${!service_mappings[@]}"; do
local target_url="https://${service_mappings[$service_name]}"
# Update specific service URLs with proper formatting # Use awk to find and update the href for each specific service
sed -i "s|# href: \"https://code\.[^\"]*\"|# href: \"https://code.$new_domain\"|g" "$SERVICES_YAML" # This finds the service by name and updates its href regardless of current URL
sed -i "s|# href: \"https://listmonk\.[^\"]*\"|# href: \"https://listmonk.$new_domain\"|g" "$SERVICES_YAML" awk -v service="$service_name" -v new_url="$target_url" '
sed -i "s|# href: \"https://db\.[^\"]*\"|# href: \"https://db.$new_domain\"|g" "$SERVICES_YAML" BEGIN { in_service = 0 }
sed -i "s|# href: \"https://git\.[^\"]*\"|# href: \"https://git.$new_domain\"|g" "$SERVICES_YAML"
sed -i "s|# href: \"https://docs\.[^\"]*\"|# href: \"https://docs.$new_domain\"|g" "$SERVICES_YAML"
sed -i "s|# href: \"https://n8n\.[^\"]*\"|# href: \"https://n8n.$new_domain\"|g" "$SERVICES_YAML"
# Update root domain reference # Check if we found the service name
sed -i "s|# href: \"https://[^/\"]*\"|# href: \"https://$new_domain\"|g" "$SERVICES_YAML" | tail -1 /- [^:]+:/ {
if ($0 ~ ("- " service ":")) {
in_service = 1
} else {
in_service = 0
}
}
echo "✅ Updated service URLs in services.yaml to use domain: $new_domain" # If we are in the target service and find href line, update it
in_service && /href:/ {
gsub(/href: "[^"]*"/, "href: \"" new_url "\"")
}
# Print the line (modified or not)
{ print }
' "$SERVICES_YAML" > "${SERVICES_YAML}.tmp"
# Replace the original file with the updated version
mv "${SERVICES_YAML}.tmp" "$SERVICES_YAML"
echo " ✓ Updated $service_name -> $target_url"
done
echo "✅ All service URLs updated to use domain: $new_domain"
return 0 return 0
} }
@ -497,9 +561,18 @@ ingress:
- hostname: map.$new_domain - hostname: map.$new_domain
service: http://localhost:${MAP_PORT:-3000} service: http://localhost:${MAP_PORT:-3000}
- hostname: influence.$new_domain
service: http://localhost:${INFLUENCE_PORT:-3333}
- hostname: qr.$new_domain - hostname: qr.$new_domain
service: http://localhost:${MINI_QR_PORT:-8089} service: http://localhost:${MINI_QR_PORT:-8089}
- hostname: prometheus.$new_domain
service: http://localhost:${PROMETHEUS_PORT:-9090}
- hostname: grafana.$new_domain
service: http://localhost:${GRAFANA_PORT:-3001}
# Catch-all rule (required) # Catch-all rule (required)
- service: http_status:404 - service: http_status:404
EOL EOL
@ -520,13 +593,134 @@ load_env_vars() {
fi fi
} }
# Function to update the map's .env file with domain settings # Function to sync ports from root .env to map .env
sync_map_ports() {
echo "Syncing ports from root .env to map configuration..."
# Load the current port values from root .env
local mkdocs_port=$(grep "^MKDOCS_PORT=" "$ENV_FILE" | cut -d'=' -f2)
local mkdocs_site_port=$(grep "^MKDOCS_SITE_SERVER_PORT=" "$ENV_FILE" | cut -d'=' -f2)
local map_port=$(grep "^MAP_PORT=" "$ENV_FILE" | cut -d'=' -f2)
# Set defaults if not found
mkdocs_port=${mkdocs_port:-4000}
mkdocs_site_port=${mkdocs_site_port:-4002}
map_port=${map_port:-3000}
# Update the map's .env with the correct ports
if grep -q "^PORT=" "$MAP_ENV_FILE"; then
sed -i "s|^PORT=.*|PORT=$map_port|" "$MAP_ENV_FILE"
else
echo "PORT=$map_port" >> "$MAP_ENV_FILE"
fi
# Add/Update MkDocs configuration
if grep -q "^MKDOCS_URL=" "$MAP_ENV_FILE"; then
sed -i "s|^MKDOCS_URL=.*|MKDOCS_URL=http://localhost:$mkdocs_site_port|" "$MAP_ENV_FILE"
else
echo "" >> "$MAP_ENV_FILE"
echo "# MkDocs Integration" >> "$MAP_ENV_FILE"
echo "MKDOCS_URL=http://localhost:$mkdocs_site_port" >> "$MAP_ENV_FILE"
fi
if grep -q "^MKDOCS_SEARCH_URL=" "$MAP_ENV_FILE"; then
sed -i "s|^MKDOCS_SEARCH_URL=.*|MKDOCS_SEARCH_URL=http://localhost:$mkdocs_site_port|" "$MAP_ENV_FILE"
else
echo "MKDOCS_SEARCH_URL=http://localhost:$mkdocs_site_port" >> "$MAP_ENV_FILE"
fi
if grep -q "^MKDOCS_SITE_SERVER_PORT=" "$MAP_ENV_FILE"; then
sed -i "s|^MKDOCS_SITE_SERVER_PORT=.*|MKDOCS_SITE_SERVER_PORT=$mkdocs_site_port|" "$MAP_ENV_FILE"
else
echo "MKDOCS_SITE_SERVER_PORT=$mkdocs_site_port" >> "$MAP_ENV_FILE"
fi
echo "✅ Synced ports to map configuration:"
echo " - Map Port: $map_port"
echo " - MkDocs Dev Port: $mkdocs_port"
echo " - MkDocs Site Port: $mkdocs_site_port"
echo " - MkDocs URL: http://localhost:$mkdocs_site_port"
}
# Function to update or create the map's .env file with domain settings
update_map_env() { update_map_env() {
local new_domain=$1 local new_domain=$1
# If the map .env file does not exist, create it with defaults
if [ ! -f "$MAP_ENV_FILE" ]; then if [ ! -f "$MAP_ENV_FILE" ]; then
echo "Warning: Map .env file not found at $MAP_ENV_FILE" echo "Map .env file not found at $MAP_ENV_FILE, creating a new one with defaults."
return 1 cat > "$MAP_ENV_FILE" <<EOL
NOCODB_API_URL=https://db.$new_domain/api/v1
NOCODB_API_TOKEN=changeme
# NocoDB View URL is the URL to your NocoDB view where the map data is stored.
NOCODB_VIEW_URL=
# NOCODB_LOGIN_SHEET is the URL to your NocoDB login sheet.
NOCODB_LOGIN_SHEET=
# NOCODB_SETTINGS_SHEET is the URL to your NocoDB settings sheet.
NOCODB_SETTINGS_SHEET=
# NOCODB_SHIFTS_SHEET is the urls to your shifts sheets.
NOCODB_SHIFTS_SHEET=
# NOCODB_SHIFT_SIGNUPS_SHEET is the URL to your NocoDB shift signups sheet where users can add their own shifts.
NOCODB_SHIFT_SIGNUPS_SHEET=
# Server Configuration
PORT=3000
NODE_ENV=production
# Session Secret (IMPORTANT: Generate a secure random string for production)
# You can generate one with: openssl rand -hex 32
SESSION_SECRET=$(openssl rand -hex 32 2>/dev/null || echo "changeme")
# Map Defaults (Edmonton, Alberta, Canada)
DEFAULT_LAT=53.5461
DEFAULT_LNG=-113.4938
DEFAULT_ZOOM=11
# Optional: Map Boundaries (prevents users from adding points outside area)
# BOUND_NORTH=53.7
# BOUND_SOUTH=53.4
# BOUND_EAST=-113.3
# BOUND_WEST=-113.7
# Cloudflare Settings
TRUST_PROXY=true
COOKIE_DOMAIN=.$new_domain
# Update NODE_ENV to production for HTTPS
NODE_ENV=production
# Add allowed origin
ALLOWED_ORIGINS=https://map.$new_domain,http://localhost:3000
# Add allowed origin
ALLOWED_ORIGINS=https://map.$new_domain,http://localhost:3000
# SMTP Configuration
SMTP_HOST=smtp.insert.here
SMTP_PORT=insert_port
SMTP_SECURE=false
SMTP_USER=changeme@$new_domain
SMTP_PASS=changeme
EMAIL_FROM_NAME="$new_domain Map"
EMAIL_FROM_ADDRESS=changeme@$new_domain
# App Configuration
APP_NAME="$new_domain Map"
# Listmonk Configuration
LISTMONK_API_URL=http://listmonk_app:9000/api
LISTMONK_PASSWORD=changeme
LISTMONK_SYNC_ENABLED=true
LISTMONK_INITIAL_SYNC=false # Set to true to sync existing data
EOL
echo "✅ Created new map .env file at $MAP_ENV_FILE"
return 0
fi fi
echo "Updating map .env file with new domain settings..." echo "Updating map .env file with new domain settings..."
@ -537,6 +731,39 @@ update_map_env() {
cp "$MAP_ENV_FILE" "$backup_file" cp "$MAP_ENV_FILE" "$backup_file"
echo "Created backup of map .env at $backup_file" echo "Created backup of map .env at $backup_file"
# Update DOMAIN
if grep -q "^DOMAIN=" "$MAP_ENV_FILE"; then
sed -i "s|^DOMAIN=.*|DOMAIN=$new_domain|" "$MAP_ENV_FILE"
else
echo "DOMAIN=$new_domain" >> "$MAP_ENV_FILE"
fi
# Update NOCODB_API_URL to use new domain
if grep -q "^NOCODB_API_URL=" "$MAP_ENV_FILE"; then
sed -i "s|^NOCODB_API_URL=.*|NOCODB_API_URL=https://db.$new_domain/api/v1|" "$MAP_ENV_FILE"
else
echo "NOCODB_API_URL=https://db.$new_domain/api/v1" >> "$MAP_ENV_FILE"
fi
# Add NOCODB_CUTS_SHEET if missing
if ! grep -q "^NOCODB_CUTS_SHEET=" "$MAP_ENV_FILE"; then
echo "NOCODB_CUTS_SHEET=" >> "$MAP_ENV_FILE"
fi
# Update MKDOCS_URL to use new domain
if grep -q "^MKDOCS_URL=" "$MAP_ENV_FILE"; then
sed -i "s|^MKDOCS_URL=.*|MKDOCS_URL=https://$new_domain|" "$MAP_ENV_FILE"
else
echo "MKDOCS_URL=https://$new_domain" >> "$MAP_ENV_FILE"
fi
# Update MKDOCS_SEARCH_URL to use new domain
if grep -q "^MKDOCS_SEARCH_URL=" "$MAP_ENV_FILE"; then
sed -i "s|^MKDOCS_SEARCH_URL=.*|MKDOCS_SEARCH_URL=https://$new_domain|" "$MAP_ENV_FILE"
else
echo "MKDOCS_SEARCH_URL=https://$new_domain" >> "$MAP_ENV_FILE"
fi
# Update COOKIE_DOMAIN # Update COOKIE_DOMAIN
if grep -q "^COOKIE_DOMAIN=" "$MAP_ENV_FILE"; then if grep -q "^COOKIE_DOMAIN=" "$MAP_ENV_FILE"; then
sed -i "s|^COOKIE_DOMAIN=.*|COOKIE_DOMAIN=.$new_domain|" "$MAP_ENV_FILE" sed -i "s|^COOKIE_DOMAIN=.*|COOKIE_DOMAIN=.$new_domain|" "$MAP_ENV_FILE"
@ -552,18 +779,257 @@ update_map_env() {
echo "ALLOWED_ORIGINS=$allowed_origins" >> "$MAP_ENV_FILE" echo "ALLOWED_ORIGINS=$allowed_origins" >> "$MAP_ENV_FILE"
fi fi
# Also update the NOCODB URLs if they contain domain references # Update EMAIL_FROM_NAME
sed -i "s|example\.org|$new_domain|g" "$MAP_ENV_FILE" if grep -q "^EMAIL_FROM_NAME=" "$MAP_ENV_FILE"; then
sed -i "s|changeme\.org|$new_domain|g" "$MAP_ENV_FILE" sed -i "s|^EMAIL_FROM_NAME=.*|EMAIL_FROM_NAME=\"$new_domain Map\"|" "$MAP_ENV_FILE"
sed -i "s|albertademocracytaskforce\.org|$new_domain|g" "$MAP_ENV_FILE" else
echo "EMAIL_FROM_NAME=\"$new_domain Map\"" >> "$MAP_ENV_FILE"
fi
# Update EMAIL_FROM_ADDRESS if it contains a domain reference
if grep -q "^EMAIL_FROM_ADDRESS=" "$MAP_ENV_FILE"; then
# Only update if it looks like it contains a domain placeholder
if grep -q "changeme@\|insert_from_address" "$MAP_ENV_FILE"; then
sed -i "s|^EMAIL_FROM_ADDRESS=.*|EMAIL_FROM_ADDRESS=changeme@$new_domain|" "$MAP_ENV_FILE"
fi
else
echo "EMAIL_FROM_ADDRESS=changeme@$new_domain" >> "$MAP_ENV_FILE"
fi
# Update SMTP_USER if it contains a domain reference
if grep -q "^SMTP_USER=" "$MAP_ENV_FILE"; then
# Only update if it looks like it contains a domain placeholder
if grep -q "changeme@\|@bnkops.ca" "$MAP_ENV_FILE"; then
sed -i "s|^SMTP_USER=.*|SMTP_USER=changeme@$new_domain|" "$MAP_ENV_FILE"
fi
else
echo "SMTP_USER=changeme@$new_domain" >> "$MAP_ENV_FILE"
fi
# Update APP_NAME
if grep -q "^APP_NAME=" "$MAP_ENV_FILE"; then
sed -i "s|^APP_NAME=.*|APP_NAME=\"$new_domain Map\"|" "$MAP_ENV_FILE"
else
echo "APP_NAME=\"$new_domain Map\"" >> "$MAP_ENV_FILE"
fi
# Update domain references in NocoDB URLs while preserving the sheet IDs and paths
# This will update domains like cmlite.org, changeme.org, etc. to the new domain
sed -i "s|://db\.cmlite\.org/|://db.$new_domain/|g" "$MAP_ENV_FILE"
sed -i "s|://map\.cmlite\.org|://map.$new_domain|g" "$MAP_ENV_FILE"
echo "✅ Updated map .env file with:" echo "✅ Updated map .env file with:"
echo " - NOCODB_API_URL=https://db.$new_domain/api/v1"
echo " - COOKIE_DOMAIN=.$new_domain" echo " - COOKIE_DOMAIN=.$new_domain"
echo " - ALLOWED_ORIGINS=$allowed_origins" echo " - ALLOWED_ORIGINS=$allowed_origins"
echo " - Updated all NocoDB URLs to use $new_domain" echo " - Updated all NocoDB URLs to use $new_domain domain"
# Sync ports to map configuration
sync_map_ports
return 0 return 0
} }
# Function to check for port conflicts in existing .env file
check_port_conflicts() {
echo "Checking for port conflicts in existing configuration..."
# Get list of all used ports on system
local used_ports_list
used_ports_list=$(get_used_ports)
if [ $? -ne 0 ] || [ -z "$used_ports_list" ]; then
echo "Warning: Could not scan system ports. Skipping conflict check."
return 1
fi
# Check each configured port
local -a conflicts=()
local -a port_vars=(
"CODE_SERVER_PORT"
"LISTMONK_PORT"
"LISTMONK_DB_PORT"
"MKDOCS_PORT"
"MKDOCS_SITE_SERVER_PORT"
"N8N_PORT"
"NOCODB_PORT"
"HOMEPAGE_PORT"
"GITEA_WEB_PORT"
"GITEA_SSH_PORT"
"MAP_PORT"
"INFLUENCE_PORT"
"MINI_QR_PORT"
"REDIS_PORT"
"PROMETHEUS_PORT"
"GRAFANA_PORT"
)
for var in "${port_vars[@]}"; do
local port_value="${!var}"
if [ -n "$port_value" ] && ! is_port_available "$port_value" "$used_ports_list"; then
conflicts+=("$var=$port_value")
fi
done
if [ ${#conflicts[@]} -gt 0 ]; then
echo ""
echo "⚠️ WARNING: Port conflicts detected!"
echo "The following ports are already in use on your system:"
for conflict in "${conflicts[@]}"; do
echo " - $conflict"
done
echo ""
echo "You may need to:"
echo "1. Stop services using these ports, or"
echo "2. Edit .env file to use different ports"
echo ""
else
echo "✅ No port conflicts detected!"
fi
return 0
}
# Function to get instance identifier
get_instance_identifier() {
# Try to get from directory name first
local dir_name=$(basename "$SCRIPT_DIR")
local default_instance=""
# Extract potential instance name from directory
if [[ "$dir_name" =~ changemaker\.lite\.(.+)$ ]]; then
default_instance="${BASH_REMATCH[1]}"
elif [[ "$dir_name" != "changemaker.lite" ]]; then
default_instance="$dir_name"
fi
# Send informational messages to stderr so they don't get captured
echo "" >&2
echo "=== Instance Configuration ===" >&2
echo "To run multiple Changemaker instances on the same machine," >&2
echo "each instance needs a unique identifier for containers and networks." >&2
echo "" >&2
if [ -n "$default_instance" ]; then
echo "Detected potential instance name from directory: $default_instance" >&2
read -p "Use this instance identifier? [Y/n]: " use_detected
if [[ ! "$use_detected" =~ ^[Nn]$ ]]; then
echo "$default_instance"
return 0
fi
fi
read -p "Enter instance identifier (letters, numbers, hyphens only) [default: main]: " instance_id
# Validate and sanitize instance identifier
if [ -z "$instance_id" ]; then
instance_id="main"
fi
# Remove invalid characters and convert to lowercase
instance_id=$(echo "$instance_id" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]//g')
if [ -z "$instance_id" ]; then
instance_id="main"
fi
# Only output the final instance_id to stdout (this gets captured)
echo "$instance_id"
}
# Function to update docker-compose.yml with unique names
update_docker_compose_names() {
local instance_id=$1
if [ -z "$instance_id" ] || [ "$instance_id" = "main" ]; then
echo "Using default container names (no instance suffix)"
return 0
fi
# Check if docker-compose.yml exists and is not empty
if [ ! -f "$DOCKER_COMPOSE_FILE" ] || [ ! -s "$DOCKER_COMPOSE_FILE" ]; then
echo "Error: docker-compose.yml does not exist or is empty at: $DOCKER_COMPOSE_FILE"
echo "Please ensure docker-compose.yml exists before running this script."
return 1
fi
echo "Updating docker-compose.yml with instance identifier: $instance_id"
# Create a backup of the docker-compose.yml file
local timestamp=$(date +"%Y%m%d_%H%M%S")
local backup_file="${DOCKER_COMPOSE_FILE}.backup_${timestamp}"
cp "$DOCKER_COMPOSE_FILE" "$backup_file"
echo "Created backup of docker-compose.yml at $backup_file"
# Create temporary file for modifications
local temp_file=$(mktemp)
# Verify temp file was created
if [ ! -f "$temp_file" ]; then
echo "Error: Could not create temporary file"
return 1
fi
# Update container names, network names, and volume names
# Process the file and save to temp file
sed \
-e "s/container_name: \([^-]*\)-changemaker$/container_name: \1-changemaker-${instance_id}/g" \
-e "s/container_name: \([^-]*\)_\([^-]*\)_changemaker$/container_name: \1_\2_changemaker_${instance_id}/g" \
-e "s/container_name: \([^-]*\)_\([^-]*\)$/container_name: \1_\2_${instance_id}/g" \
-e "s/container_name: \([^-]*\)$/container_name: \1-${instance_id}/g" \
-e "s/changemaker-lite:/changemaker-lite-${instance_id}:/g" \
-e "s/- changemaker-lite$/- changemaker-lite-${instance_id}/g" \
-e "s/listmonk-data:/listmonk-data-${instance_id}:/g" \
-e "s/source: listmonk-data$/source: listmonk-data-${instance_id}/g" \
-e "s/n8n_data:/n8n_data_${instance_id}:/g" \
-e "s/source: n8n_data$/source: n8n_data_${instance_id}/g" \
-e "s/nc_data:/nc_data_${instance_id}:/g" \
-e "s/source: nc_data$/source: nc_data_${instance_id}/g" \
-e "s/db_data:/db_data_${instance_id}:/g" \
-e "s/source: db_data$/source: db_data_${instance_id}/g" \
-e "s/gitea_data:/gitea_data_${instance_id}:/g" \
-e "s/source: gitea_data$/source: gitea_data_${instance_id}/g" \
-e "s/mysql_data:/mysql_data_${instance_id}:/g" \
-e "s/source: mysql_data$/source: mysql_data_${instance_id}/g" \
-e "s/redis-data:/redis-data-${instance_id}:/g" \
-e "s/source: redis-data$/source: redis-data-${instance_id}/g" \
-e "s/prometheus-data:/prometheus-data-${instance_id}:/g" \
-e "s/source: prometheus-data$/source: prometheus-data-${instance_id}/g" \
-e "s/grafana-data:/grafana-data-${instance_id}:/g" \
-e "s/source: grafana-data$/source: grafana-data-${instance_id}/g" \
"$DOCKER_COMPOSE_FILE" > "$temp_file"
# Check if temp file has content
if [ ! -s "$temp_file" ]; then
echo "Error: sed operation produced empty file"
echo "Restoring from backup..."
cp "$backup_file" "$DOCKER_COMPOSE_FILE"
rm -f "$temp_file"
return 1
fi
# Replace the original file only if temp file has content
mv "$temp_file" "$DOCKER_COMPOSE_FILE"
echo "✅ Updated docker-compose.yml with instance-specific names:"
echo " - Container names: *-${instance_id}"
echo " - Network name: changemaker-lite-${instance_id}"
echo " - Volume names: *-${instance_id}"
return 0
}
# Function to update .env file with instance identifier
update_env_instance_config() {
local instance_id=$1
if [ -n "$instance_id" ] && [ "$instance_id" != "main" ]; then
update_env_var "INSTANCE_ID" "$instance_id"
update_env_var "COMPOSE_PROJECT_NAME" "changemaker-lite-${instance_id}"
echo "Updated .env with instance configuration"
fi
}
# Initialize a new .env file if it doesn't exist # Initialize a new .env file if it doesn't exist
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ]; then
echo "No .env file found. Creating a new one from scratch." echo "No .env file found. Creating a new one from scratch."
@ -587,6 +1053,26 @@ echo -e "\n\nWelcome to Changemaker Config!\n"
echo "This script will help you configure your .env file for Changemaker." echo "This script will help you configure your .env file for Changemaker."
echo "Please provide the following information:" echo "Please provide the following information:"
# Get instance identifier and update docker-compose.yml
echo -e "\n---- Instance Configuration ----"
instance_identifier=$(get_instance_identifier)
# Strip any whitespace/newlines from the captured value
instance_identifier=$(echo "$instance_identifier" | tr -d '\n\r' | xargs)
# Only update docker-compose.yml if we have a non-default instance ID
if [ -n "$instance_identifier" ] && [ "$instance_identifier" != "main" ]; then
if update_docker_compose_names "$instance_identifier"; then
echo "✅ Docker Compose configuration updated successfully"
else
echo "⚠️ Warning: Failed to update docker-compose.yml"
echo " Continuing with default configuration..."
fi
else
echo "Using default instance configuration (no modifications to docker-compose.yml)"
fi
update_env_instance_config "$instance_identifier"
# Domain configuration # Domain configuration
read -p "Enter your domain name (without protocol, e.g., example.com): " domain_name read -p "Enter your domain name (without protocol, e.g., example.com): " domain_name
@ -739,10 +1225,15 @@ update_env_var "GITEA_DB_PASSWD" "$gitea_db_password"
gitea_db_root_password=$(generate_password 20) gitea_db_root_password=$(generate_password 20)
update_env_var "GITEA_DB_ROOT_PASSWORD" "$gitea_db_root_password" update_env_var "GITEA_DB_ROOT_PASSWORD" "$gitea_db_root_password"
# Generate and update Grafana admin password
grafana_admin_password=$(generate_password 20)
update_env_var "GRAFANA_ADMIN_PASSWORD" "$grafana_admin_password"
echo "Secure passwords generated and updated." echo "Secure passwords generated and updated."
echo -e "\n✅ Configuration completed successfully!" echo -e "\n✅ Configuration completed successfully!"
echo "Your .env file has been configured with:" echo "Your .env file has been configured with:"
echo "- Instance ID: $instance_identifier"
echo "- Domain: $domain_name" echo "- Domain: $domain_name"
echo "- Cookie Domain: .$domain_name" echo "- Cookie Domain: .$domain_name"
echo "- Allowed Origins: https://map.$domain_name,http://localhost:3000" echo "- Allowed Origins: https://map.$domain_name,http://localhost:3000"
@ -750,6 +1241,8 @@ echo "- Map .env updated with domain settings"
echo "- Listmonk Admin: $listmonk_user" echo "- Listmonk Admin: $listmonk_user"
echo "- N8N Admin Email: $n8n_email" echo "- N8N Admin Email: $n8n_email"
echo "- Secure random passwords for database, encryption, and NocoDB" echo "- Secure random passwords for database, encryption, and NocoDB"
echo "- Grafana Admin Password: Generated (see .env file)"
echo "- Centralized services: Redis, Prometheus, Grafana"
echo "- Tunnel configuration updated at: $TUNNEL_CONFIG_FILE" echo "- Tunnel configuration updated at: $TUNNEL_CONFIG_FILE"
echo -e "\nYour .env file is located at: $ENV_FILE" echo -e "\nYour .env file is located at: $ENV_FILE"
echo "A backup of your original .env file was created before modifications." echo "A backup of your original .env file was created before modifications."
@ -759,6 +1252,10 @@ echo "======================================"
echo "Next Steps:" echo "Next Steps:"
echo "======================================" echo "======================================"
echo "" echo ""
if [ -n "$instance_identifier" ] && [ "$instance_identifier" != "main" ]; then
echo "Instance: $instance_identifier"
echo ""
fi
echo "1. Start services locally:" echo "1. Start services locally:"
echo " docker compose up -d" echo " docker compose up -d"
echo "" echo ""
@ -771,8 +1268,16 @@ echo " - n8n: http://localhost:${N8N_PORT:-5678}"
echo " - NocoDB: http://localhost:${NOCODB_PORT:-8090}" echo " - NocoDB: http://localhost:${NOCODB_PORT:-8090}"
echo " - Gitea: http://localhost:${GITEA_WEB_PORT:-3030}" echo " - Gitea: http://localhost:${GITEA_WEB_PORT:-3030}"
echo " - Map: http://localhost:${MAP_PORT:-3000}" echo " - Map: http://localhost:${MAP_PORT:-3000}"
echo " - Influence: http://localhost:${INFLUENCE_PORT:-3333}"
echo " - Mini QR: http://localhost:${MINI_QR_PORT:-8089}" echo " - Mini QR: http://localhost:${MINI_QR_PORT:-8089}"
echo "" echo ""
echo " Centralized Services (optional monitoring profile):"
echo " - Prometheus: http://localhost:${PROMETHEUS_PORT:-9090}"
echo " - Grafana: http://localhost:${GRAFANA_PORT:-3001} (admin/${GRAFANA_ADMIN_PASSWORD})"
echo ""
echo " To start with monitoring:"
echo " docker compose --profile monitoring up -d"
echo ""
echo "3. When ready for production:" echo "3. When ready for production:"
echo " ./start-production.sh" echo " ./start-production.sh"
echo "" echo ""

View File

@ -1 +0,0 @@
{"AccountTag":"a421828402ca13fbcaad955f285f16f6","TunnelSecret":"949mtkdN202INtEohadCgnEe7QXykBL26dq2uQMt+HQ=","TunnelID":"843f83a4-247a-4c29-8a7e-cde50e2f4222","Endpoint":""}

View File

@ -1,8 +1,8 @@
# Cloudflare Tunnel Configuration for cmlite.org # Cloudflare Tunnel Configuration for cmlite.org
# Generated by Changemaker.lite start-production.sh on Sun 29 Jun 2025 09:10:15 PM MDT # Generated by Changemaker.lite start-production.sh on Sat Jul 5 09:07:25 PM MDT 2025
tunnel: 843f83a4-247a-4c29-8a7e-cde50e2f4222 tunnel: 0447884a-8052-41fa-9ff1-f6d16abdc5e1
credentials-file: /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/cloudflare/843f83a4-247a-4c29-8a7e-cde50e2f4222.json credentials-file: /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/cloudflare/0447884a-8052-41fa-9ff1-f6d16abdc5e1.json
ingress: ingress:
- hostname: homepage.cmlite.org - hostname: homepage.cmlite.org
service: http://localhost:3010 service: http://localhost:3010
@ -24,4 +24,6 @@ ingress:
service: http://localhost:3000 service: http://localhost:3000
- hostname: qr.cmlite.org - hostname: qr.cmlite.org
service: http://localhost:8089 service: http://localhost:8089
- hostname: influence.cmlite.org
service: http://localhost:3333
- service: http_status:404 - service: http_status:404

View File

@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: 'default'
orgId: 1
folder: ''
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /etc/grafana/provisioning/dashboards

View File

@ -0,0 +1,11 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus-changemaker:9090
isDefault: true
editable: true
jsonData:
timeInterval: 15s

View File

@ -701,3 +701,5 @@
at async NextNodeServer.handleRequestImpl (/app/node_modules/.pnpm/next@15.3.1_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/base-server.js:899:17) at async NextNodeServer.handleRequestImpl (/app/node_modules/.pnpm/next@15.3.1_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/base-server.js:899:17)
at async invokeRender (/app/node_modules/.pnpm/next@15.3.1_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/router-server.js:237:21) at async invokeRender (/app/node_modules/.pnpm/next@15.3.1_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/router-server.js:237:21)
at async handleRequest (/app/node_modules/.pnpm/next@15.3.1_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/router-server.js:428:24) at async handleRequest (/app/node_modules/.pnpm/next@15.3.1_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/router-server.js:428:24)
[2025-07-27T23:30:12.346Z] error: <httpProxy> Error calling https://api.open-meteo.com/v1/forecast...
[2025-07-27T23:30:12.352Z] error: <httpProxy> [ 500, [AggregateError: ] { code: 'ETIMEDOUT' } ]

View File

@ -9,12 +9,6 @@
description: VS Code in the browser - Platform Editor description: VS Code in the browser - Platform Editor
container: code-server-changemaker container: code-server-changemaker
- Listmonk:
icon: mdi-email-newsletter
href: "https://listmonk.cmlite.org"
description: Newsletter & mailing list manager
container: listmonk_app
- NocoDB: - NocoDB:
icon: mdi-database icon: mdi-database
href: "https://db.cmlite.org" href: "https://db.cmlite.org"
@ -27,6 +21,12 @@
description: Map server for geospatial data description: Map server for geospatial data
container: nocodb-map-viewer container: nocodb-map-viewer
- Influence:
icon: mdi-account-group
href: "https://influence.cmlite.org"
description: Political influence and campaign management
container: influence-app-1
- Content & Documentation: - Content & Documentation:
- Main Site: - Main Site:
@ -47,6 +47,11 @@
description: QR code generator description: QR code generator
container: mini-qr container: mini-qr
- Listmonk:
icon: mdi-email-newsletter
href: "https://listmonk.cmlite.org"
description: Newsletter & mailing list manager
container: listmonk_app
- Automation & Infrastructure: - Automation & Infrastructure:
- n8n: - n8n:

View File

@ -0,0 +1,49 @@
server {
listen 80;
server_name _;
root /config/www;
index index.html;
# CRITICAL: Include MIME types
include /etc/nginx/mime.types;
# Handle trailing slashes for directories
location ~ ^([^.]*[^/])$ {
try_files $uri $uri/ $uri.html =404;
}
# CORS configuration for search index
location /search/search_index.json {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, Accept' always;
if ($request_method = 'OPTIONS') {
return 204;
}
}
# Main location block
location / {
add_header 'Access-Control-Allow-Origin' '*' always;
try_files $uri $uri/ $uri/index.html $uri.html /index.html =404;
}
# Handle 404 with MkDocs 404 page
error_page 404 /404.html;
location = /404.html {
internal;
}
# Static assets
location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
add_header 'Access-Control-Allow-Origin' '*' always;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Enable gzip
gzip on;
gzip_types text/plain text/css text/javascript application/javascript application/json;
}

View File

@ -0,0 +1,93 @@
groups:
- name: influence_app_alerts
interval: 30s
rules:
# Application availability
- alert: ApplicationDown
expr: up{job="influence-app"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Influence application is down"
description: "The Influence application has been down for more than 2 minutes."
# High error rate
- alert: HighErrorRate
expr: rate(influence_http_requests_total{status_code=~"5.."}[5m]) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "High error rate detected"
description: "Application is experiencing {{ $value }} errors per second."
# Email queue backing up
- alert: EmailQueueBacklog
expr: influence_email_queue_size > 100
for: 10m
labels:
severity: warning
annotations:
summary: "Email queue has significant backlog"
description: "Email queue size is {{ $value }}, emails may be delayed."
# High email failure rate
- alert: HighEmailFailureRate
expr: rate(influence_emails_failed_total[5m]) / rate(influence_emails_sent_total[5m]) > 0.2
for: 10m
labels:
severity: warning
annotations:
summary: "High email failure rate"
description: "{{ $value | humanizePercentage }} of emails are failing to send."
# Rate limiting being hit frequently
- alert: FrequentRateLimiting
expr: rate(influence_rate_limit_hits_total[5m]) > 1
for: 5m
labels:
severity: info
annotations:
summary: "Rate limiting triggered frequently"
description: "Rate limits are being hit {{ $value }} times per second."
# Memory usage high
- alert: HighMemoryUsage
expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) > 0.85
for: 10m
labels:
severity: warning
annotations:
summary: "High memory usage"
description: "Memory usage is above 85% ({{ $value | humanizePercentage }})."
# Failed login attempts spike
- alert: SuspiciousLoginActivity
expr: rate(influence_login_attempts_total{status="failed"}[5m]) > 5
for: 2m
labels:
severity: warning
annotations:
summary: "Suspicious login activity detected"
description: "{{ $value }} failed login attempts per second detected."
# External service failures
- alert: ExternalServiceFailures
expr: rate(influence_external_service_requests_total{status="failed"}[5m]) > 0.5
for: 5m
labels:
severity: warning
annotations:
summary: "External service failures detected"
description: "{{ $labels.service }} is failing at {{ $value }} requests per second."
# High API latency
- alert: HighAPILatency
expr: histogram_quantile(0.95, rate(influence_http_request_duration_seconds_bucket[5m])) > 2
for: 5m
labels:
severity: warning
annotations:
summary: "High API latency"
description: "95th percentile latency is {{ $value }}s for {{ $labels.route }}."

View File

@ -0,0 +1,54 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
monitor: 'changemaker-lite'
# Alertmanager configuration (optional)
alerting:
alertmanagers:
- static_configs:
- targets: []
# Load rules once and periodically evaluate them
rule_files:
- "alerts.yml"
# Scrape configurations
scrape_configs:
# Influence Application Metrics
- job_name: 'influence-app'
static_configs:
- targets: ['influence-app:3333']
metrics_path: '/api/metrics'
scrape_interval: 10s
scrape_timeout: 5s
# N8N Metrics (if available)
- job_name: 'n8n'
static_configs:
- targets: ['n8n-changemaker:5678']
metrics_path: '/metrics'
scrape_interval: 30s
# Redis Metrics (requires redis_exporter - optional)
# Uncomment and add redis_exporter service to enable
# - job_name: 'redis'
# static_configs:
# - targets: ['redis-exporter:9121']
# Listmonk Metrics (if available)
# - job_name: 'listmonk'
# static_configs:
# - targets: ['listmonk-app:9000']
# metrics_path: '/metrics'
# Prometheus self-monitoring
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
# Docker container metrics (requires cAdvisor - optional)
# - job_name: 'cadvisor'
# static_configs:
# - targets: ['cadvisor:8080']

View File

@ -21,16 +21,16 @@ services:
- "${CODE_SERVER_PORT:-8888}:8080" - "${CODE_SERVER_PORT:-8888}:8080"
restart: unless-stopped restart: unless-stopped
networks: networks:
- changemaker-lite - changemaker-lite-test4
listmonk-app: listmonk-app:
image: listmonk/listmonk:latest image: listmonk/listmonk:latest
container_name: listmonk_app container_name: listmonk_app_test4-test4
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${LISTMONK_PORT:-9000}:9000" - "${LISTMONK_PORT:-9001}:9000"
networks: networks:
- changemaker-lite - changemaker-lite-test4
hostname: ${LISTMONK_HOSTNAME} hostname: ${LISTMONK_HOSTNAME}
depends_on: depends_on:
- listmonk-db - listmonk-db
@ -41,11 +41,11 @@ services:
LISTMONK_db__password: *db-password LISTMONK_db__password: *db-password
LISTMONK_db__database: *db-name LISTMONK_db__database: *db-name
LISTMONK_db__host: listmonk-db LISTMONK_db__host: listmonk-db
LISTMONK_db__port: 5432 LISTMONK_db__port: ${LISTMONK_DB_PORT:-5432}
LISTMONK_db__ssl_mode: disable LISTMONK_db__ssl_mode: disable
LISTMONK_db__max_open: 25 LISTMONK_db__max_open: ${LISTMONK_DB_MAX_OPEN:-25}
LISTMONK_db__max_idle: 25 LISTMONK_db__max_idle: ${LISTMONK_DB_MAX_IDLE:-25}
LISTMONK_db__max_lifetime: 300s LISTMONK_db__max_lifetime: ${LISTMONK_DB_MAX_LIFETIME:-300s}
TZ: Etc/UTC TZ: Etc/UTC
LISTMONK_ADMIN_USER: ${LISTMONK_ADMIN_USER:-} LISTMONK_ADMIN_USER: ${LISTMONK_ADMIN_USER:-}
LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD:-} LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD:-}
@ -54,12 +54,12 @@ services:
listmonk-db: listmonk-db:
image: postgres:17-alpine image: postgres:17-alpine
container_name: listmonk_db container_name: listmonk_db_test4-test4
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:${LISTMONK_DB_PORT:-5432}:5432" - "127.0.0.1:${LISTMONK_DB_PORT:-5432}:5432"
networks: networks:
- changemaker-lite - changemaker-lite-test4
environment: environment:
<<: *db-credentials <<: *db-credentials
healthcheck: healthcheck:
@ -69,12 +69,12 @@ services:
retries: 6 retries: 6
volumes: volumes:
- type: volume - type: volume
source: listmonk-data source: listmonk-data-test4
target: /var/lib/postgresql/data target: /var/lib/postgresql/data
mkdocs: mkdocs:
image: squidfunk/mkdocs-material image: squidfunk/mkdocs-material
container_name: mkdocs-changemaker container_name: mkdocs-changemaker-test4
volumes: volumes:
- ./mkdocs:/docs:rw - ./mkdocs:/docs:rw
- ./assets/images:/docs/assets/images:rw - ./assets/images:/docs/assets/images:rw
@ -83,29 +83,30 @@ services:
- "${MKDOCS_PORT:-4000}:8000" - "${MKDOCS_PORT:-4000}:8000"
environment: environment:
- SITE_URL=${BASE_DOMAIN:-https://changeme.org} - SITE_URL=${BASE_DOMAIN:-https://changeme.org}
command: serve --dev-addr=0.0.0.0:8000 --watch-theme --livereload --dirty command: serve --dev-addr=0.0.0.0:8000 --watch-theme --livereload
networks: networks:
- changemaker-lite - changemaker-lite-test4
restart: unless-stopped restart: unless-stopped
mkdocs-site-server: mkdocs-site-server:
image: lscr.io/linuxserver/nginx:latest image: lscr.io/linuxserver/nginx:latest
container_name: mkdocs-site-server-changemaker container_name: mkdocs-site-server-changemaker
environment: environment:
- PUID=${USER_ID:-1000} # Uses USER_ID from your .env file, defaults to 1000 - PUID=${USER_ID:-1000}
- PGID=${GROUP_ID:-1000} # Uses GROUP_ID from your .env file, defaults to 1000 - PGID=${GROUP_ID:-1000}
- TZ=Etc/UTC - TZ=Etc/UTC
volumes: volumes:
- ./mkdocs/site:/config/www # Mounts your static site to Nginx's web root - ./mkdocs/site:/config/www
- ./configs/mkdocs-site/default.conf:/config/nginx/site-confs/default.conf # Add this line
ports: ports:
- "${MKDOCS_SITE_SERVER_PORT:-4001}:80" # Exposes Nginx's port 80 to host port 4001 - "${MKDOCS_SITE_SERVER_PORT:-4001}:80"
restart: unless-stopped restart: unless-stopped
networks: networks:
- changemaker-lite - changemaker-lite-test4
n8n: n8n:
image: docker.n8n.io/n8nio/n8n image: docker.n8n.io/n8nio/n8n
container_name: n8n-changemaker container_name: n8n-changemaker-test4
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${N8N_PORT:-5678}:5678" - "${N8N_PORT:-5678}:5678"
@ -121,10 +122,10 @@ services:
- N8N_DEFAULT_USER_EMAIL=${N8N_USER_EMAIL:-admin@example.com} - N8N_DEFAULT_USER_EMAIL=${N8N_USER_EMAIL:-admin@example.com}
- N8N_DEFAULT_USER_PASSWORD=${N8N_USER_PASSWORD:-changeMe} - N8N_DEFAULT_USER_PASSWORD=${N8N_USER_PASSWORD:-changeMe}
volumes: volumes:
- n8n_data:/home/node/.n8n - n8n_data_test4:/home/node/.n8n
- ./local-files:/files - ./local-files:/files
networks: networks:
- changemaker-lite - changemaker-lite-test4
nocodb: nocodb:
depends_on: depends_on:
@ -137,9 +138,9 @@ services:
- "${NOCODB_PORT:-8090}:8080" - "${NOCODB_PORT:-8090}:8080"
restart: always restart: always
volumes: volumes:
- "nc_data:/usr/app/data" - "nc_data_test4:/usr/app/data"
networks: networks:
- changemaker-lite - changemaker-lite-test4
root_db: root_db:
environment: environment:
POSTGRES_DB: root_db POSTGRES_DB: root_db
@ -153,14 +154,14 @@ services:
image: postgres:16.6 image: postgres:16.6
restart: always restart: always
volumes: volumes:
- "db_data:/var/lib/postgresql/data" - "db_data_test4:/var/lib/postgresql/data"
networks: networks:
- changemaker-lite - changemaker-lite-test4
# Homepage App # Homepage App
homepage-changemaker: homepage-changemaker:
image: ghcr.io/gethomepage/homepage:latest image: ghcr.io/gethomepage/homepage:latest
container_name: homepage-changemaker container_name: homepage-changemaker-test4
ports: ports:
- "${HOMEPAGE_PORT:-3010}:3000" - "${HOMEPAGE_PORT:-3010}:3000"
volumes: volumes:
@ -176,12 +177,12 @@ services:
- HOMEPAGE_VAR_BASE_URL=${HOMEPAGE_VAR_BASE_URL:-http://localhost} - HOMEPAGE_VAR_BASE_URL=${HOMEPAGE_VAR_BASE_URL:-http://localhost}
restart: unless-stopped restart: unless-stopped
networks: networks:
- changemaker-lite - changemaker-lite-test4
# Gitea - Git service # Gitea - Git service
gitea-app: gitea-app:
image: gitea/gitea:1.23.7 image: gitea/gitea:1.23.7
container_name: gitea_changemaker container_name: gitea_changemaker_test4-test4
environment: environment:
- USER_UID=${USER_ID:-1000} - USER_UID=${USER_ID:-1000}
- USER_GID=${GROUP_ID:-1000} - USER_GID=${GROUP_ID:-1000}
@ -200,9 +201,9 @@ services:
- GITEA__server__PROXY_ALLOW_SUBNET=0.0.0.0/0 - GITEA__server__PROXY_ALLOW_SUBNET=0.0.0.0/0
restart: unless-stopped restart: unless-stopped
networks: networks:
- changemaker-lite - changemaker-lite-test4
volumes: volumes:
- gitea_data:/data - gitea_data_test4:/data
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
ports: ports:
@ -213,7 +214,7 @@ services:
gitea-db: gitea-db:
image: mysql:8 image: mysql:8
container_name: gitea_mysql_changemaker container_name: gitea_mysql_changemaker_test4_test4-test4
restart: unless-stopped restart: unless-stopped
environment: environment:
- MYSQL_ROOT_PASSWORD=${GITEA_DB_ROOT_PASSWORD} - MYSQL_ROOT_PASSWORD=${GITEA_DB_ROOT_PASSWORD}
@ -221,9 +222,9 @@ services:
- MYSQL_PASSWORD=${GITEA_DB_PASSWD} - MYSQL_PASSWORD=${GITEA_DB_PASSWD}
- MYSQL_DATABASE=${GITEA_DB_NAME:-gitea} - MYSQL_DATABASE=${GITEA_DB_NAME:-gitea}
networks: networks:
- changemaker-lite - changemaker-lite-test4
volumes: volumes:
- mysql_data:/var/lib/mysql - mysql_data_test4:/var/lib/mysql
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${GITEA_DB_USER:-gitea}", "-p${GITEA_DB_PASSWD}"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${GITEA_DB_USER:-gitea}", "-p${GITEA_DB_PASSWD}"]
interval: 10s interval: 10s
@ -237,16 +238,109 @@ services:
- "${MINI_QR_PORT:-8089}:8080" - "${MINI_QR_PORT:-8089}:8080"
restart: unless-stopped restart: unless-stopped
networks: networks:
- changemaker-lite - changemaker-lite-test4
# Shared Redis - Used by all services for caching, queues, sessions
redis:
image: redis:7-alpine
container_name: redis-changemaker-test4
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- redis-data-test4:/data
restart: always
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
networks:
- changemaker-lite-test4
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "2"
# Prometheus - Metrics collection for all services
prometheus:
image: prom/prometheus:latest
container_name: prometheus-changemaker-test4
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d'
ports:
- "${PROMETHEUS_PORT:-9090}:9090"
volumes:
- ./configs/prometheus:/etc/prometheus
- prometheus-data-test4:/prometheus
restart: always
networks:
- changemaker-lite-test4
profiles:
- monitoring
# Grafana - Metrics visualization for all services
grafana:
image: grafana/grafana:latest
container_name: grafana-changemaker-test4
ports:
- "${GRAFANA_PORT:-3001}:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
- GF_USERS_ALLOW_SIGN_UP=false
- GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-http://localhost:3001}
volumes:
- grafana-data-test4:/var/lib/grafana
- ./configs/grafana:/etc/grafana/provisioning
restart: always
depends_on:
- prometheus
networks:
- changemaker-lite-test4
profiles:
- monitoring
# MailHog - Shared email testing service for all applications
# Captures all emails sent by any service for development/testing
# Web UI: http://localhost:8025
# SMTP: mailhog-changemaker:1025
mailhog:
image: mailhog/mailhog:latest
container_name: mailhog-changemaker-test4
ports:
- "1025:1025" # SMTP server
- "8025:8025" # Web UI
restart: unless-stopped
networks:
- changemaker-lite-test4
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "2"
networks: networks:
changemaker-lite: changemaker-lite-test4:
driver: bridge driver: bridge
volumes: volumes:
listmonk-data: listmonk-data-test4:
n8n_data: n8n_data_test4:
nc_data: nc_data_test4:
db_data: db_data_test4:
gitea_data: gitea_data_test4:
mysql_data: mysql_data_test4:
redis-data-test4:
prometheus-data-test4:
grafana-data-test4:

View File

@ -1,40 +0,0 @@
# Cloudflare Configuration Guide for Changemaker.lite
This guide will help you properly configure Cloudflare credentials for use with Changemaker.lite's production deployment.
## Finding Your Zone ID
The Zone ID is a unique identifier for your domain in Cloudflare.
1. Log in to the [Cloudflare Dashboard](https://dash.cloudflare.com)
2. Select your domain (e.g., cmlite.org)
3. On the right sidebar under "API", you'll see "Zone ID"
4. Copy this value - it should look something like: `1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p`
## Creating an API Token
You need an API token with proper permissions to manage DNS records and access policies.
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com) > Profile (top right) > API Tokens
2. Click "Create Token"
3. Use the "Edit zone DNS" template (or create a custom token)
4. For a custom token, ensure it has the following permissions:
- Zone - DNS - Edit
- Zone - Settings - Read
- Account - Cloudflare Tunnel - Read
5. Under "Zone Resources", select "Include - Specific zone" and choose your domain
6. Create the token and copy its value
## Finding Your Account ID
The Account ID is needed for some Cloudflare Tunnel operations.
1. Go to the [Cloudflare Dashboard](https://dash.cloudflare.com)
2. Look at the URL when logged in - it will contain your account ID:
`https://dash.cloudflare.com/1a2b3c4d5e6f7g8h9i0j/your-domain.com`
3. The string after `/dash.cloudflare.com/` is your Account ID (e.g., `1a2b3c4d5e6f7g8h9i0j`)
## Updating Your .env File
Update your `.env` file with these values:

View File

@ -0,0 +1,271 @@
---
title: "API Reference | Represent Elected Officials and Electoral Districts API for Canada"
source: "https://represent.opennorth.ca/api/"
author:
published:
created: 2025-09-17
description: "Find the elected officials and electoral districts for any Canadian address or postal code, at all levels of government"
tags:
- "clippings"
---
### Endpoints
The base URL of all endpoints is `https://represent.opennorth.ca`. All endpoints output JSON.
- [Postal codes](https://represent.opennorth.ca/api/#postcode)
- [Boundary sets](https://represent.opennorth.ca/api/#boundaryset)
- [Boundaries](https://represent.opennorth.ca/api/#boundary)
- [Representative sets](https://represent.opennorth.ca/api/#representativeset)
- [Representatives](https://represent.opennorth.ca/api/#representative)
- [Elections](https://represent.opennorth.ca/api/#election)
- [Candidates](https://represent.opennorth.ca/api/#candidate)
### Paginate
Results are paginated 20 per page by default. Set the number of results per page by adding a `limit` query parameter. Change pages using the `offset` query parameter or using the `next` and `previous` links under the `meta` field in the response to navigate to the next and previous pages (if any). Under the `meta` field, `total_count` is the number of results.
### Filter results
Filter results with query parameters. Each endpoint below lists the fields on which you can filter results. To filter for representatives whose first name is “Rodney”, for example, request `/representatives/?first_name=Rodney`. To filter for MPs whose first name is "Rodney", request `/representatives/house-of-commons/?last_name=Rodney`.
Perform substring searches by appending `__querytype` to the parameter name, where `querytype` is one of `iexact`, `contains`, `icontains`, `startswith`, `istartswith`, `endswith`, `iendswith` or `isnull`. A leading `i` makes the match case-insensitive. For example, to find representatives whose last name starts with “M” or “m”, request `/representatives/?last_name__istartswith=m`.
### Download in bulk
To download all representatives, send a request to [https://represent.opennorth.ca/representatives/?limit=1000](https://represent.opennorth.ca/representatives/?limit=1000) and follow the `next` link under the `meta` field until you reach the end. We host the shapefiles and postal code concordances on [GitHub](https://github.com/opennorth/represent-canada-data).
### Rate limits
Represent is free up to 60 requests per minute (86,400 queries/day). If you need to make more queries, [contact us](https://represent.opennorth.ca/api/); otherwise, you may get HTTP 503 errors.
### Debugging
For a browsable, HTML version of the JSON response, add a `format=apibrowser` query parameter. Add `pretty=1` to just indent the raw JSON.
### JSONP
We support JSONP for client-side cross-domain requests just add a `callback` query parameter.
### Libraries
- [Drupal](https://drupal.org/project/represent)
- [WordPress](https://wordpress.org/plugins/represent-api/)
- [Ruby](https://github.com/opennorth/govkit-ca#readme)
- [Ruby](https://github.com/cpb/opennorth-represent#readme) by Caleb Buxton
- [Python](https://github.com/ncadou/pyrepresent#readme) by Nicolas Cadou
- [Node.js](https://github.com/sprice/represent#readme) by Shawn Price
- [CiviCRM](https://drupal.org/project/civinorth) by Alan Dixon
[Privacy policy](https://represent.opennorth.ca/privacy/)
Find representatives and boundaries by postal code.
To see what boundary sets and representative sets are available, consult the [boundary sets](https://represent.opennorth.ca/api/#boundaryset) and [representative sets](https://represent.opennorth.ca/api/#representativeset) endpoints. Are we missing information that you need? [Contact us](https://represent.opennorth.ca/api/) so that we can make it a priority.
### Request
URLs must include the postal code in uppercase letters with no spaces.
### Response
The `boundaries_centroid` field lists boundaries that contain the postal codes center point (centroid). A centroid is a point, but a postal code can be a line or polygon, so the list of boundaries in `boundaries_centroid` **will sometimes be inaccurate**.
The `boundaries_concordance` field lists boundaries linked to the postal code according to official government data. Postal codes can cross boundaries, therefore `boundaries_concordance` may list many Ontario provincial districts for a postal code like K0A 1K0.
The `representatives_centroid` and `representatives_concordance` fields behave similarly.
In most cases, the `city`, `province` and `centroid` fields will be non-empty.
Find representatives and boundaries by postal code
[/postcodes/L5G4L3/](https://represent.opennorth.ca/postcodes/L5G4L3/?format=apibrowser) Click to view JSON
Find representatives and boundaries by postal code, limiting results to a specific boundary set
[/postcodes/L5G4L3/?sets=federal-electoral-districts](https://represent.opennorth.ca/postcodes/L5G4L3/?sets=federal-electoral-districts&format=apibrowser)
To see what boundary sets are available, consult the [boundary sets](https://represent.opennorth.ca/api/#boundaryset) endpoint.
A boundary set is a group of electoral districts, like BC provincial districts or Toronto wards.
Do we not have a set of boundaries that you need? [Contact us](https://represent.opennorth.ca/api/) so that we can make it a priority.
Get one page of boundary sets
[/boundary-sets/](https://represent.opennorth.ca/boundary-sets/?format=apibrowser) Click to view JSON
Get one boundary set
[/boundary-sets/federal-electoral-districts/](https://represent.opennorth.ca/boundary-sets/federal-electoral-districts/?format=apibrowser)
Filter boundary sets by `name` or `domain`
[/boundary-sets/?domain=Canada](https://represent.opennorth.ca/boundary-sets/?domain=Canada&format=apibrowser)
The response's `external_id` field (not always present) is the boundary's machine identifier. The `metadata` field contains all attributes from the source shapefile; it is unmodified and may be out-of-date or erroneous.
Get one page of boundaries
[/boundaries/](https://represent.opennorth.ca/boundaries/?format=apibrowser) Click to view JSON
Get one page of boundaries from a boundary set
[/boundaries/toronto-wards-2018/](https://represent.opennorth.ca/boundaries/toronto-wards-2018/?format=apibrowser)
To see what boundary sets are available, consult the [boundary sets](https://represent.opennorth.ca/api/#boundaryset) endpoint.
Get one page of boundaries from multiple boundary sets (comma-separated)
[/boundaries/?sets=toronto-wards-2018,ottawa-wards](https://represent.opennorth.ca/boundaries/?sets=toronto-wards-2018,ottawa-wards&format=apibrowser)
To see what boundary sets are available, consult the [boundary sets](https://represent.opennorth.ca/api/#boundaryset) endpoint.
Get one boundary
[/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/](https://represent.opennorth.ca/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/?format=apibrowser)
Filter all boundaries by `name` or `external_id`
[/boundaries/?name=Niagara Falls](https://represent.opennorth.ca/boundaries/?name=Niagara%20Falls&format=apibrowser)
Filter a boundary set's boundaries by `name` or `external_id`
[/boundaries/census-subdivisions/?name=Niagara Falls](https://represent.opennorth.ca/boundaries/census-subdivisions/?name=Niagara%20Falls&format=apibrowser)
To see what boundary sets are available, consult the [boundary sets](https://represent.opennorth.ca/api/#boundaryset) endpoint.
### Geospatial queries
Find all boundaries by latitude and longitude
[/boundaries/?contains=45.524,-73.596](https://represent.opennorth.ca/boundaries/?contains=45.524,-73.596&format=apibrowser)
Find a boundary set's boundaries by latitude and longitude
[/boundaries/montreal-boroughs/?contains=45.524,-73.596](https://represent.opennorth.ca/boundaries/montreal-boroughs/?contains=45.524,-73.596&format=apibrowser)
To see what boundary sets are available, consult the [boundary sets](https://represent.opennorth.ca/api/#boundaryset) endpoint.
Find boundaries that touch
[/boundaries/?touches=alberta-electoral-districts-2017/highwood](https://represent.opennorth.ca/boundaries/?touches=alberta-electoral-districts-2017/highwood&format=apibrowser)
Find boundaries that intersect (“covers or overlaps” in PostGIS lingo)
[/boundaries/?intersects=alberta-electoral-districts-2017/highwood](https://represent.opennorth.ca/boundaries/?intersects=alberta-electoral-districts-2017/highwood&format=apibrowser)
### Drawing boundaries
We recommend the `simple_shape` endpoint, which simplifies the shape to a tolerance of 0.002, looks fine and loads fast. The default geospatial output format is GeoJSON. Add a `format=kml` or `format=wkt` query parameter to get KML or Well-Known Text.
Get all simple shapes from a boundary set
[/boundaries/toronto-wards-2018/simple\_shape](https://represent.opennorth.ca/boundaries/toronto-wards-2018/simple_shape?format=apibrowser)
Get all original shapes from a boundary set
[/boundaries/toronto-wards-2018/shape](https://represent.opennorth.ca/boundaries/toronto-wards-2018/shape?format=apibrowser)
Get all centroids from a boundary set
[/boundaries/toronto-wards-2018/centroid](https://represent.opennorth.ca/boundaries/toronto-wards-2018/centroid?format=apibrowser)
Get one boundary's simple shape
[/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/simple\_shape](https://represent.opennorth.ca/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/simple_shape?format=apibrowser)
Get one boundary's original shape
[/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/shape](https://represent.opennorth.ca/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/shape?format=apibrowser)
Get one boundary's centroid
[/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/centroid](https://represent.opennorth.ca/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/centroid?format=apibrowser)
A representative set is a group of elected officials, like the House of Commons or Toronto City Council.
Do we not have a set of representatives that you need? [Contact us](https://represent.opennorth.ca/api/) so that we can make it a priority.
Get one page of representative sets
[/representative-sets/](https://represent.opennorth.ca/representative-sets/?format=apibrowser) Click to view JSON
Get one representative set
[/representative-sets/ontario-legislature/](https://represent.opennorth.ca/representative-sets/ontario-legislature/?format=apibrowser)
Get one page of representatives
[/representatives/](https://represent.opennorth.ca/representatives/?format=apibrowser) Click to view JSON
Get one page of representatives from a representative set
[/representatives/house-of-commons/](https://represent.opennorth.ca/representatives/house-of-commons/?format=apibrowser)
To see what representative sets are available, consult the [representative sets](https://represent.opennorth.ca/api/#representativeset) endpoint.
Find all representatives by latitude and longitude
[/representatives/?point=45.524,-73.596](https://represent.opennorth.ca/representatives/?point=45.524,-73.596&format=apibrowser)
Find a representative set's representatives by latitude and longitude
[/representatives/house-of-commons/?point=45.524,-73.596](https://represent.opennorth.ca/representatives/house-of-commons/?point=45.524,-73.596&format=apibrowser)
To see what representative sets are available, consult the [representative sets](https://represent.opennorth.ca/api/#representativeset) endpoint.
Get the representatives for one boundary
[/boundaries/toronto-wards-2018/etobicoke-north-1/representatives/](https://represent.opennorth.ca/boundaries/toronto-wards-2018/etobicoke-north-1/representatives/?format=apibrowser)
Get the representatives for multiple boundaries (comma-separated)
[/representatives/?districts=calgary-wards/ward-1,calgary-wards/ward-2,calgary-wards/ward-3](https://represent.opennorth.ca/representatives/?districts=calgary-wards/ward-1,calgary-wards/ward-2,calgary-wards/ward-3&format=apibrowser)
Filter all representatives by `name`, `first_name`, `last_name`, `gender`, `district_name`, `elected_office` or `party_name`
[/representatives/?last\_name=Trudeau](https://represent.opennorth.ca/representatives/?last_name=Trudeau&format=apibrowser)
Filter a representative set's representatives by `name`, `first_name`, `last_name`, `gender`, `district_name`, `elected_office` or `party_name`
[/representatives/house-of-commons/?last\_name=Trudeau](https://represent.opennorth.ca/representatives/house-of-commons/?last_name=Trudeau&format=apibrowser)
To see what representative sets are available, consult the [representative sets](https://represent.opennorth.ca/api/#representativeset) endpoint.
Only the **bold** fields are present in all responses:
| Field | Example | Notes |
| --- | --- | --- |
| **name** | Stephen Harper | |
| **district\_name** | Calgary Southwest | |
| **elected\_office** | MP, MLA, Mayor, Councillor, Alderman | |
| **source\_url** | The URL at which the data is scraped | May be the same as `url` below |
| first\_name | Stephen | |
| last\_name | Harper | |
| party\_name | Conservative | |
| email | example@example.com | |
| url | https://legislature.ca/stephen-harper | The representatives page on the official legislature site |
| photo\_url | https://legislature.ca/stephen-harper.jpg | |
| personal\_url | https://stephenharper.blogspot.com/ | A site run by the representative thats not on the official legislature site |
| district\_id | 24013 | If theres an identifier besides the district name |
| gender | M, F | |
| offices | `[ {"postal": "10 North Pole, H0H 0H0", "tel": "555-555-5555", "type": "constituency"}, {"tel": "444-444-4444", "type": "legislature"} ]` | A list of objects with contact information for the representatives offices. The keys are: `postal` (mailing address), `tel` (telephone), `fax` (facsimile), `type` (what kind of office this is, e.g. constituency or legislature). |
| extra | `{ "hair_colour": "brown" }` | Any extra data |
This endpoint behaves like the [/representative-sets/](https://represent.opennorth.ca/api/#representativeset) endpoint. See its documentation for more examples.
If you would like to add an election to Represent, [contact us](https://represent.opennorth.ca/api/).
Get one page of elections
[/elections/](https://represent.opennorth.ca/elections/?format=apibrowser) Click to view JSON
This endpoint behaves like the [/representatives/](https://represent.opennorth.ca/api/#representative) endpoint. See its documentation for more examples.
Candidate lists may be incomplete or incorrect, as this information changes frequently.
If you would like to add candidates to Represent, [contact us](https://represent.opennorth.ca/api/).
Get one page of candidates
[/candidates/](https://represent.opennorth.ca/candidates/?format=apibrowser) Click to view JSON

View File

@ -0,0 +1,161 @@
# CSRF Security Update - Fix Summary
## Date: October 23, 2025
## Issues Encountered
After implementing CSRF security updates, the application experienced two main issues:
### 1. Login Failed with "Invalid CSRF token"
**Problem**: The login endpoint required a CSRF token, but users couldn't get a token before logging in (chicken-and-egg problem).
**Root Cause**: The `/api/auth/login` endpoint was being protected by CSRF middleware, but there's no session yet during initial login.
**Solution**: Added `/api/auth/login` and `/api/auth/session` to the CSRF exempt routes list in `app/middleware/csrf.js`. Login endpoints use credentials (username/password) for authentication, so they don't need CSRF protection.
### 2. Campaign Creation Failed with Infinite Retry Loop
**Problem**: When creating campaigns, the app would get stuck in an infinite retry loop with repeated "CSRF token validation failed" errors.
**Root Causes**:
1. The API client (`api-client.js`) wasn't fetching or sending CSRF tokens at all
2. The retry logic didn't have a guard against infinite recursion
3. FormData wasn't including the CSRF token
**Solutions**:
1. **Added CSRF token management** to the API client:
- `fetchCsrfToken()` - Fetches token from `/api/csrf-token` endpoint
- `ensureCsrfToken()` - Ensures a valid token exists before requests
- Tokens are automatically included in state-changing requests (POST, PUT, PATCH, DELETE)
2. **Fixed infinite retry loop**:
- Added `isRetry` parameter to `makeRequest()`, `postFormData()`, and `putFormData()`
- Retry only happens once per request
- If second attempt fails, error is thrown to the user
3. **Enhanced token handling**:
- JSON requests: Token sent via `X-CSRF-Token` header
- FormData requests: Token sent via `_csrf` field
- Token automatically refreshed if server responds with new token
4. **Server-side updates**:
- Added explicit CSRF protection to `/api/csrf-token` endpoint so it can generate tokens
- Exported `csrfProtection` middleware for explicit use
## Files Modified
### 1. `app/middleware/csrf.js`
```javascript
// Added to exempt routes:
const csrfExemptRoutes = [
'/api/health',
'/api/metrics',
'/api/config',
'/api/auth/login', // ← NEW: Login uses credentials
'/api/auth/session', // ← NEW: Session check is read-only
'/api/representatives/postal/',
'/api/campaigns/public'
];
// Enhanced getCsrfToken with error handling
```
### 2. `app/server.js`
```javascript
// Added csrfProtection to imports
const { conditionalCsrfProtection, getCsrfToken, csrfProtection } = require('./middleware/csrf');
// Applied explicit CSRF protection to token endpoint
app.get('/api/csrf-token', csrfProtection, getCsrfToken);
```
### 3. `app/public/js/api-client.js`
- Added CSRF token caching and fetching logic
- Modified `makeRequest()` to include `X-CSRF-Token` header
- Modified `postFormData()` and `putFormData()` to include `_csrf` field
- Added retry logic with infinite loop protection (max 1 retry)
- Added automatic token refresh on 403 errors
## How CSRF Protection Works Now
### Flow for State-Changing Requests (POST, PUT, DELETE):
```
1. User Action (e.g., "Create Campaign")
2. API Client checks if CSRF token exists
↓ (if no token)
3. Fetch token from GET /api/csrf-token
4. Include token in request:
- Header: X-CSRF-Token (for JSON)
- FormData: _csrf (for file uploads)
5. Server validates token matches session
6a. Success → Process request
6b. Invalid Token → Return 403
↓ (on 403, if not a retry)
7. Clear token, fetch new one, retry ONCE
8a. Success → Return data
8b. Still fails → Throw error to user
```
### Protected vs Exempt Endpoints
**Protected (requires CSRF token)**:
- ✅ POST `/api/admin/campaigns` - Create campaign
- ✅ PUT `/api/admin/campaigns/:id` - Update campaign
- ✅ POST `/api/emails/send` - Send email
- ✅ POST `/api/auth/logout` - Logout
- ✅ POST `/api/auth/change-password` - Change password
**Exempt (no CSRF required)**:
- ✅ GET (all GET requests are safe)
- ✅ POST `/api/auth/login` - Uses credentials
- ✅ GET `/api/auth/session` - Read-only check
- ✅ GET `/api/health` - Public health check
- ✅ GET `/api/metrics` - Prometheus metrics
## Testing Checklist
- [x] Login as admin works
- [ ] Create new campaign works
- [ ] Update existing campaign works
- [ ] Delete campaign works
- [ ] Send email to representative works
- [ ] Logout works
- [ ] Password change works
- [ ] Public pages work without authentication
## Security Benefits
1. **CSRF Attack Prevention**: Malicious sites can't forge requests to your app
2. **Session Hijacking Protection**: httpOnly, secure, sameSite cookies
3. **Defense in Depth**: Multiple security layers (Helmet, rate limiting, CSRF, validation)
4. **Automatic Token Rotation**: Tokens refresh on each response when available
5. **Retry Logic**: Handles token expiration gracefully
## Important Notes
- CSRF tokens are tied to sessions and expire with the session (1 hour)
- Tokens are stored in cookies (httpOnly, secure in production)
- The retry logic prevents infinite loops by limiting to 1 retry per request
- Login doesn't need CSRF because it uses credentials for authentication
- All state-changing operations (POST/PUT/DELETE) now require valid CSRF tokens
## Troubleshooting
**If you see "Invalid CSRF token" errors:**
1. Check browser console for detailed error messages
2. Clear browser cookies and session storage
3. Logout and login again to get a fresh session
4. Verify the session hasn't expired (1 hour timeout)
5. Check server logs for CSRF validation failures
**If infinite retry loop occurs:**
1. Check that `isRetry` parameter is being passed correctly
2. Verify FormData isn't being reused across retries
3. Clear the API client's cached token: `window.apiClient.csrfToken = null`

View File

@ -0,0 +1,243 @@
# Custom Recipients Implementation
## Overview
This feature allows campaigns to target any email address (custom recipients) instead of or in addition to elected representatives from the Represent API.
## Implementation Summary
### ✅ Backend (Complete)
#### 1. Database Schema (`scripts/build-nocodb.sh`)
- **custom_recipients table** with fields:
- `id` - Primary key
- `campaign_id` - Links to campaigns table
- `campaign_slug` - Campaign identifier
- `recipient_name` - Full name of recipient
- `recipient_email` - Email address
- `recipient_title` - Job title/position (optional)
- `recipient_organization` - Organization name (optional)
- `notes` - Internal notes (optional)
- `is_active` - Boolean flag
- **campaigns table** updated:
- Added `allow_custom_recipients` boolean field (default: false)
#### 2. Backend Controller (`app/controllers/customRecipients.js`)
Full CRUD operations:
- `getRecipientsByCampaign(req, res)` - Fetch all recipients for a campaign
- `createRecipient(req, res)` - Add single recipient with validation
- `bulkCreateRecipients(req, res)` - Import multiple recipients from CSV
- `updateRecipient(req, res)` - Update recipient details
- `deleteRecipient(req, res)` - Delete single recipient
- `deleteAllRecipients(req, res)` - Clear all recipients for a campaign
#### 3. NocoDB Service (`app/services/nocodb.js`)
- `getCustomRecipients(campaignId)` - Query by campaign ID
- `getCustomRecipientsBySlug(campaignSlug)` - Query by slug
- `createCustomRecipient(recipientData)` - Create with field mapping
- `updateCustomRecipient(recipientId, updateData)` - Partial updates
- `deleteCustomRecipient(recipientId)` - Single deletion
- `deleteCustomRecipientsByCampaign(campaignId)` - Bulk deletion
#### 4. API Routes (`app/routes/api.js`)
All routes protected with `requireNonTemp` authentication:
- `GET /api/campaigns/:slug/custom-recipients` - List all recipients
- `POST /api/campaigns/:slug/custom-recipients` - Create single recipient
- `POST /api/campaigns/:slug/custom-recipients/bulk` - Bulk import
- `PUT /api/campaigns/:slug/custom-recipients/:id` - Update recipient
- `DELETE /api/campaigns/:slug/custom-recipients/:id` - Delete recipient
- `DELETE /api/campaigns/:slug/custom-recipients` - Delete all recipients
#### 5. Campaign Controller Updates (`app/controllers/campaigns.js`)
- Added `allow_custom_recipients` field to all campaign CRUD operations
- Field normalization in 5+ locations for consistent API responses
### ✅ Frontend (Complete)
#### 1. JavaScript Module (`app/public/js/custom-recipients.js`)
Comprehensive module with:
- **CRUD Operations**: Add, edit, delete recipients
- **Bulk Import**: CSV file upload or paste with parsing
- **Validation**: Email format validation
- **UI Management**: Dynamic recipient list display with cards
- **Error Handling**: User-friendly error messages
- **XSS Protection**: HTML escaping for security
Key methods:
```javascript
CustomRecipients.init(campaignSlug) // Initialize module
CustomRecipients.loadRecipients(slug) // Load from API
CustomRecipients.displayRecipients() // Render list
// Plus handleAddRecipient, handleEditRecipient, handleDeleteRecipient, etc.
```
#### 2. Admin Panel Integration (`app/public/admin.html` + `app/public/js/admin.js`)
- **Create Form**: Checkbox to enable custom recipients
- **Edit Form**:
- Checkbox with show/hide toggle
- Add recipient form (5 fields: name, email, title, organization, notes)
- Bulk CSV import button with modal
- Recipients list with edit/delete actions
- Clear all button
- **JavaScript Integration**:
- `toggleCustomRecipientsSection()` - Show/hide based on checkbox
- `setupCustomRecipientsHandlers()` - Event listeners for checkbox
- Auto-load recipients when editing campaign with feature enabled
- Form data includes `allow_custom_recipients` in create/update
#### 3. Bulk Import Modal (`app/public/admin.html`)
Complete modal with:
- CSV format instructions
- File upload input
- Paste textarea for direct CSV input
- Import results display with success/failure details
- CSV format: `recipient_name,recipient_email,recipient_title,recipient_organization,notes`
#### 4. CSS Styling (`app/public/admin.html`)
- `.recipient-card` - Card layout with hover effects
- `.recipient-info` - Name, email, metadata display
- `.recipient-actions` - Edit/delete icon buttons with hover colors
- `.bulk-import-help` - Modal styling
- Responsive grid layout
## Usage
### For Administrators:
1. **Create Campaign**:
- Check "Allow Custom Recipients" during creation
- An info section will appear explaining that recipients can be added after campaign is created
- Complete the campaign creation
2. **Edit Campaign**:
- Navigate to the Edit tab and select your campaign
- Check "Allow Custom Recipients" to enable the feature
- The custom recipients management section will appear below the checkbox
3. **Add Single Recipient**:
- Fill in name (required) and email (required)
- Optionally add title, organization, notes
- Click "Add Recipient"
4. **Bulk Import**:
- Click "Bulk Import (CSV)" button
- Upload CSV file or paste CSV data
- CSV format: `recipient_name,recipient_email,recipient_title,recipient_organization,notes`
- First row can be header (will be skipped if contains "recipient_name")
- Results show success/failure for each row
5. **Edit Recipient**:
- Click edit icon on recipient card
- Form populates with current data
- Make changes and click "Update Recipient"
- Or click "Cancel" to revert
6. **Delete Recipients**:
- Single: Click delete icon on card
- All: Click "Clear All" button
### API Examples:
```bash
# Create recipient
curl -X POST /api/campaigns/my-campaign/custom-recipients \
-H "Content-Type: application/json" \
-d '{
"recipient_name": "Jane Doe",
"recipient_email": "jane@example.com",
"recipient_title": "CEO",
"recipient_organization": "Tech Corp"
}'
# Bulk import
curl -X POST /api/campaigns/my-campaign/custom-recipients/bulk \
-H "Content-Type: application/json" \
-d '{
"recipients": [
{"recipient_name": "John Smith", "recipient_email": "john@example.com"},
{"recipient_name": "Jane Doe", "recipient_email": "jane@example.com"}
]
}'
# Get all recipients
curl /api/campaigns/my-campaign/custom-recipients
# Update recipient
curl -X PUT /api/campaigns/my-campaign/custom-recipients/123 \
-H "Content-Type: application/json" \
-d '{"recipient_title": "CTO"}'
# Delete recipient
curl -X DELETE /api/campaigns/my-campaign/custom-recipients/123
# Delete all recipients
curl -X DELETE /api/campaigns/my-campaign/custom-recipients
```
## Security Features
- **Authentication**: All API routes require non-temporary user session
- **Validation**: Email format validation on client and server
- **XSS Protection**: HTML escaping in display
- **Campaign Check**: Verifies campaign exists and feature is enabled
- **Input Sanitization**: express-validator on API endpoints
## Next Steps (TODO)
1. **Dashboard Integration**: Add same UI to `dashboard.html` for regular users
2. **Campaign Display**: Update `campaign.js` to show custom recipients alongside elected officials
3. **Email Composer**: Ensure custom recipients work in email sending flow
4. **Testing**: Comprehensive end-to-end testing
5. **Documentation**: Update main README and files-explainer
## Files Modified/Created
### Backend:
- ✅ `scripts/build-nocodb.sh` - Database schema
- ✅ `app/controllers/customRecipients.js` - NEW FILE (282 lines)
- ✅ `app/services/nocodb.js` - Service methods
- ✅ `app/routes/api.js` - API endpoints
- ✅ `app/controllers/campaigns.js` - Field updates
### Frontend:
- ✅ `app/public/js/custom-recipients.js` - NEW FILE (538 lines)
- ✅ `app/public/js/admin.js` - Integration code
- ✅ `app/public/admin.html` - UI components and forms
### Documentation:
- ✅ `CUSTOM_RECIPIENTS_IMPLEMENTATION.md` - This file
## Testing Checklist
- [ ] Database table creation (run build-nocodb.sh)
- [ ] Create campaign with custom recipients enabled
- [ ] Add single recipient via form
- [ ] Edit recipient information
- [ ] Delete single recipient
- [ ] Bulk import via CSV file
- [ ] Bulk import via paste
- [ ] Clear all recipients
- [ ] Toggle checkbox on/off
- [ ] Verify API authentication
- [ ] Test with campaign where feature is disabled
- [ ] Check recipient display on campaign page
- [ ] Test email sending to custom recipients
## Known Limitations
1. Custom recipients can only be added AFTER campaign is created (not during creation)
2. Dashboard UI not yet implemented (admin panel only)
3. Campaign display page doesn't show custom recipients yet
4. CSV import uses simple comma splitting (doesn't handle quoted commas)
5. No duplicate email detection
## Future Enhancements
- [ ] Duplicate email detection/prevention
- [ ] Import validation preview before saving
- [ ] Export recipients to CSV
- [ ] Recipient groups/categories
- [ ] Import from external sources (Google Contacts, etc.)
- [ ] Recipient engagement tracking
- [ ] Custom fields for recipients
- [ ] Merge tags in email templates using recipient data

View File

@ -0,0 +1,119 @@
# Debugging Custom Recipients Feature
## Changes Made to Fix Checkbox Toggle
### Issue
The "Allow Custom Recipients" checkbox wasn't showing/hiding the custom recipients management section when clicked.
### Root Causes
1. **Event Listener Timing**: Original code tried to attach event listeners during `init()`, but the edit form elements didn't exist yet
2. **Not Following Best Practices**: Wasn't using event delegation pattern as required by `instruct.md`
### Solution
Switched to **event delegation** pattern using a single document-level listener:
```javascript
// OLD (didn't work - elements didn't exist yet):
const editCheckbox = document.getElementById('edit-allow-custom-recipients');
if (editCheckbox) {
editCheckbox.addEventListener('change', handler);
}
// NEW (works with event delegation):
document.addEventListener('change', (e) => {
if (e.target.id === 'edit-allow-custom-recipients') {
// Handle the change
}
});
```
### Benefits of Event Delegation
1. ✅ Works regardless of when elements are added to DOM
2. ✅ Follows `instruct.md` rules about using `addEventListener`
3. ✅ No need to reattach listeners when switching tabs
4. ✅ Single listener handles all checkbox changes efficiently
### Console Logs Added for Debugging
The following console logs were added to help trace execution:
1. **admin.js init()**: "AdminPanel init started" and "AdminPanel init completed"
2. **custom-recipients.js load**: "Custom Recipients module loading..." and "Custom Recipients module initialized"
3. **setupCustomRecipientsHandlers()**: "Setting up custom recipients handlers" and "Custom recipients handlers set up with event delegation"
4. **Checkbox change**: "Custom recipients checkbox changed: true/false"
5. **Module init**: "Initializing CustomRecipients module for campaign: [slug]"
6. **toggleCustomRecipientsSection()**: "Toggling custom recipients section: true/false" and "Section display set to: block/none"
### Testing Steps
1. **Open Browser Console** (F12)
2. **Navigate to Admin Panel** → Look for "AdminPanel init started"
3. **Look for Module Load** → "Custom Recipients module loading..."
**Test Create Form:**
4. **Switch to Create Tab** → Click "Create New Campaign"
5. **Check the Checkbox** → "Allow Custom Recipients"
6. **Verify Info Section Appears** → Should see: "Custom recipients can only be added after the campaign is created"
7. **Console Should Show**: "Create form: Custom recipients checkbox changed: true"
**Test Edit Form:**
8. **Switch to Edit Tab** → Select a campaign
9. **Check the Checkbox** → "Allow Custom Recipients"
10. **You Should See**:
- "Custom recipients checkbox changed: true"
- "Toggling custom recipients section: true"
- "Section display set to: block"
- "Initializing CustomRecipients module for campaign: [slug]"
11. **Verify Section Appears** → The "Manage Custom Recipients" section with forms should now be visible
### If It Still Doesn't Work
Check the following in browser console:
1. **Are scripts loading?**
```
Look for: "Custom Recipients module loading..."
If missing: Check network tab for 404 errors on custom-recipients.js
```
2. **Is event delegation working?**
```
Look for: "Custom recipients handlers set up with event delegation"
If missing: Check if setupCustomRecipientsHandlers() is being called
```
3. **Is checkbox being detected?**
```
Click checkbox and look for: "Custom recipients checkbox changed: true"
If missing: Check if checkbox ID is correct in HTML
```
4. **Is section element found?**
```
Look for: "section found: [object HTMLDivElement]"
If it says "section found: null": Check if section ID matches in HTML
```
5. **Manual test in console:**
```javascript
// Check if checkbox exists
document.getElementById('edit-allow-custom-recipients')
// Check if section exists
document.getElementById('edit-custom-recipients-section')
// Check if module loaded
window.CustomRecipients
// Manually toggle section
document.getElementById('edit-custom-recipients-section').style.display = 'block';
```
### Files Modified
- ✅ `app/public/js/admin.js` - Changed to event delegation pattern, added logging
- ✅ `app/public/js/custom-recipients.js` - Added loading logs
- ✅ No changes needed to HTML (already correct)
### Next Steps After Confirming It Works
1. Remove excessive console.log statements (or convert to debug mode)
2. Test full workflow: add recipient, edit, delete, bulk import
3. Proceed with dashboard.html integration

575
influence/README.MD Normal file
View File

@ -0,0 +1,575 @@
# BNKops Influence Campaign Tool
A comprehensive web application that helps Alberta residents connect with their elected representatives across all levels of government. Users can find their representatives by postal code and send direct emails to advocate for important issues.
## Features
- **Representative Lookup**: Find elected officials by Alberta postal code (T prefixed)
- **Multi-Level Government**: Displays federal MPs, provincial MLAs, and municipal representatives
- **Contact Information**: Shows photos, email addresses, phone numbers, and office locations
- **Direct Email**: Built-in email composer to contact representatives
- **Campaign Management**: Create and manage advocacy campaigns with customizable settings
- **Public Campaigns Grid**: Homepage display of all active campaigns for easy discovery and participation
- **Response Wall**: Community-driven platform for sharing and voting on representative responses
- **Email Count Display**: Optional engagement metrics showing total emails sent per campaign
- **Smart Caching**: Fast performance with NocoDB caching and graceful fallback to live API
- **Responsive Design**: Works seamlessly on desktop and mobile devices
- **Real-time Data**: Integrates with Represent OpenNorth API for up-to-date information
## Technology Stack
- **Backend**: Node.js with Express.js
- **Database**: NocoDB (REST API)
- **External API**: Represent OpenNorth Canada API
- **Frontend**: Vanilla JavaScript, HTML5, CSS3
- **Email**: SMTP integration
- **Deployment**: Docker with docker-compose
- **Rate Limiting**: Express rate limiter for API protection
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Access to existing NocoDB instance
- SMTP email configuration
### Installation
1. **Clone and navigate to the project**:
```bash
cd /path/to/changemaker.lite/influence
```
2. **Configure environment**:
```bash
cp .env.example .env
# Edit .env with your configuration
```
3. **Set up NocoDB tables**:
```bash
./scripts/build-nocodb.sh
```
4. **Start the application**:
```bash
docker compose up --build
```
5. **Access the application**:
- Open http://localhost:3333
- Enter an Alberta postal code (e.g., T5N4B8)
- View your representatives and send emails
## Development Mode
### Email Testing with MailHog
For development and testing, the application includes MailHog integration to safely test email functionality without sending real emails to elected officials.
#### Quick Setup for Development
1. **Use development configuration**:
```bash
# Your .env should include these settings for development:
NODE_ENV=development
EMAIL_TEST_MODE=true
SMTP_HOST=mailhog
SMTP_PORT=1025
SMTP_USER=test
SMTP_PASS=test
TEST_EMAIL_RECIPIENT=your-email@example.com
```
2. **Start with MailHog included**:
```bash
docker compose up --build
```
3. **Access development tools**:
- **Application**: http://localhost:3333
- **Email Testing Interface**: http://localhost:3333/email-test.html (admin login required)
- **MailHog Web UI**: http://localhost:8025 (view all caught emails)
#### Email Testing Features
**Test Mode Benefits:**
- ✅ All emails redirected to your test recipient
- ✅ Original recipient shown in subject line: `[TEST - Original: real@email.com] Subject`
- ✅ Safe testing without spamming elected officials
- ✅ Complete email logging with test mode indicators
**Email Testing Interface** (`/email-test.html`):
- **Quick Test**: Send test email with one click
- **Email Preview**: See exactly how emails will look before sending
- **Custom Composition**: Test with your own subject and message content
- **Email Logs**: View all sent emails with test/live filtering
- **SMTP Diagnostics**: Test connection and troubleshoot issues
**MailHog Web Interface** (`http://localhost:8025`):
- View all emails caught during development
- Inspect email content, headers, and formatting
- Search and filter caught emails
- No emails leave your local environment
#### Development Workflow
1. **Safe Development**:
```bash
# Ensure test mode is enabled
EMAIL_TEST_MODE=true
# Start development environment
docker compose up --build
```
2. **Test Email Functionality**:
- Use the main app to send emails (they'll be redirected)
- Check MailHog UI to see the actual email content
- Use `/email-test.html` for advanced testing and preview
3. **Production Deployment**:
```bash
# Switch to production SMTP settings
EMAIL_TEST_MODE=false
SMTP_HOST=smtp.your-provider.com
SMTP_USER=your-real-email@domain.com
SMTP_PASS=your-real-password
# Restart application
docker compose restart
```
#### Development Environment Variables
```bash
# Development Mode Configuration
NODE_ENV=development
EMAIL_TEST_MODE=true
# MailHog SMTP (for development)
SMTP_HOST=mailhog
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_USER=test
SMTP_PASS=test
SMTP_FROM_EMAIL=dev@albertainfluence.local
SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"
# Email Testing
TEST_EMAIL_RECIPIENT=developer@example.com
# Production SMTP (commented out for dev)
# SMTP_HOST=smtp.protonmail.ch
# SMTP_PORT=587
# SMTP_USER=your-production-email@domain.com
# SMTP_PASS=your-production-password
```
## Configuration
### Environment Variables (.env)
```bash
# Server Configuration
NODE_ENV=production
PORT=3333
# NocoDB Configuration
NOCODB_API_URL=https://db.cmlite.org
NOCODB_API_TOKEN=your_nocodb_token
NOCODB_PROJECT_ID=your_project_id
# Email Configuration (SMTP)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your_email@gmail.com
SMTP_PASS=your_app_password
SMTP_FROM_NAME=BNKops Influence Campaign
SMTP_FROM_EMAIL=your_email@gmail.com
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
```
## Campaign Management Guide
### Creating a Campaign
1. **Access Admin Panel**: Navigate to `/admin.html` and log in with admin credentials
2. **Create New Campaign**: Click "Create Campaign" button
3. **Configure Basic Settings**:
- **Campaign Title**: Short, descriptive name (becomes the URL slug)
- **Description**: Brief overview shown on the campaign landing page
- **Call to Action**: Motivational message encouraging participation
4. **Set Email Template**:
- **Email Subject**: Pre-filled subject line for emails
- **Email Body**: Default message template (users may edit if allowed)
5. **Upload Cover Photo** (Optional):
- Click "Choose File" to upload a hero image
- Supported formats: JPEG, PNG, GIF, WebP
- Maximum size: 5MB
- Image displays as campaign page banner
6. **Configure Campaign Settings**:
- **📧 Allow SMTP Email**: Enable server-side email sending
- **🔗 Allow Mailto Link**: Enable browser-based mailto: links
- **👤 Collect User Info**: Request user name and email
- **📊 Show Email Count**: Display total emails sent (engagement metric)
- **✏️ Allow Email Editing**: Let users customize email template
- **⭐ Highlight Campaign**: Feature this campaign on the homepage (replaces postal code search)
- **🎯 Target Government Levels**: Select Federal, Provincial, Municipal, School Board
7. **Set Campaign Status**:
- **Draft**: Hidden from public, testing mode
- **Active**: Visible to public on main page
- **Paused**: Temporarily disabled
- **Archived**: Completed campaigns
8. **Save Campaign**: Click "Create Campaign" to publish
### Public Campaigns Display
The homepage automatically displays all active campaigns in a responsive grid below the representative lookup section.
**Features**:
- **Automatic Display**: Only active campaigns (status="active") are shown publicly
- **Campaign Cards**: Each campaign displays as an attractive card with:
- Cover photo (if uploaded) or gradient background
- Campaign title and truncated description
- Target government level badges (Federal, Provincial, Municipal, etc.)
- Email count badge (if enabled via campaign settings)
- "Learn More & Participate" call-to-action
- **Responsive Grid**: Automatically adjusts columns based on screen size
- Desktop: 3-4 columns
- Tablet: 2 columns
- Mobile: 1 column
- **Click Navigation**: Users can click any campaign card to visit the full campaign page
### Highlighted Campaign Feature
Promote a priority campaign by highlighting it on the homepage, replacing the postal code search section with featured campaign information.
**How to Highlight a Campaign**:
1. Navigate to Admin Panel → Edit Campaign
2. Select the campaign you want to feature
3. Check the "⭐ Highlight Campaign" checkbox
4. Save changes
**Highlighted Campaign Display**:
- **Homepage Takeover**: Replaces postal code search with campaign showcase
- **Featured Badge**: Shows "⭐ Featured Campaign" badge
- **Campaign Details**: Displays title, description, and engagement stats
- **Primary CTA**: Large "Join This Campaign" button
- **Fallback Option**: "Find Representatives by Postal Code" button for users who want standard lookup
- **Visual Indicators**: Gold border and badge in admin panel campaign list
**Important Notes**:
- **One at a Time**: Only ONE campaign can be highlighted simultaneously
- **Auto-Unset**: Setting a new highlighted campaign automatically removes highlighting from previous campaign
- **Requires Active Status**: Campaign must have status="active" to be highlighted
- **Admin Control**: Only administrators can set highlighted campaigns
**Technical Implementation**:
- Backend validates and ensures single highlighted campaign via `setHighlightedCampaign()`
- Frontend checks `/public/highlighted-campaign` API on page load
- Postal code lookup remains accessible via button click
- Highlighting state persists across page reloads
- **Smart Loading**: Shows loading state while fetching campaigns, gracefully hides section if no active campaigns exist
- **Security**: HTML content is escaped to prevent XSS attacks
- **Sorting**: Campaigns display newest first by creation date
**Public API Endpoint**: `/api/public/campaigns` (no authentication required)
- Returns only campaigns with `status='active'`
- Includes email counts when `show_email_count=true`
- Optimized for performance with minimal data transfer
### Email Count Display Feature
The **Show Email Count** setting controls whether campaign pages display total engagement metrics.
**When Enabled** (✅ checked):
- Campaign page shows: "X Albertans have sent emails through this campaign"
- Provides social proof and encourages participation
- Updates in real-time as users send emails
- Displays prominently above the call-to-action
**When Disabled** (❌ unchecked):
- Email count section is hidden
- Useful for sensitive campaigns or privacy concerns
- Engagement metrics still tracked in admin panel
**Best Practices**:
- ✅ Enable for public awareness campaigns to show momentum
- ✅ Enable for volunteer recruitment to demonstrate support
- ❌ Disable for personal advocacy or sensitive issues
- ❌ Disable for new campaigns until participation grows
**Technical Details**:
- Count includes all successfully sent emails via campaign
- Tracks both SMTP-sent and mailto-initiated emails (if logged)
- Admin panel always shows counts regardless of public display setting
- Database field: `show_email_count` (checkbox, default: true)
### Editing Campaigns
1. Navigate to Admin Panel → "Campaigns" tab
2. Find campaign card and click "Edit"
3. Modify any settings including the email count display toggle
4. Save changes - updates apply immediately to public-facing page
### Campaign Analytics
Access campaign performance metrics in the Admin Panel:
- Total emails sent per campaign
- User participation rates
- Email delivery status
- Representative contact distribution
## API Endpoints
### Representatives
- `GET /api/representatives/by-postal/:postalCode` - Get representatives by postal code
- `POST /api/representatives/refresh-postal/:postalCode` - Refresh cached data
### Email
- `POST /api/emails/send` - Send email to representative
- `GET /api/emails/logs` - Get email sending logs (with filters)
### Email Testing (Development)
- `POST /api/emails/preview` - Preview email without sending (admin only)
- `POST /api/emails/test` - Send test email to configured recipient (admin only)
- `GET /api/test-smtp` - Test SMTP connection (admin only)
### Health
- `GET /api/health` - Application health check
- `GET /api/test-represent` - Test Represent API connection
## Database Schema
### Campaigns Table
- slug, title, description
- email_subject, email_body
- call_to_action, cover_photo
- status (draft/active/paused/archived)
- allow_smtp_email, allow_mailto_link
- collect_user_info, **show_email_count**
- allow_email_editing
- target_government_levels (MultiSelect)
- created_by_user_id, created_by_user_email, created_by_user_name
### Campaign Emails Table
- campaign_id, user_name, user_email, user_postal_code
- recipient_name, recipient_email, recipient_level
- subject, message, status, sent_at
### Representatives Table
- postal_code, name, email, district_name
- elected_office, party_name, representative_set_name
- url, photo_url, cached_at
### Email Logs Table
- recipient_email, recipient_name, sender_email
- subject, message, status, sent_at
### Postal Codes Table
- postal_code, city, province
- centroid_lat, centroid_lng, last_updated
### Users Table
- email, password_hash, name
- role (admin/user), status (active/temporary)
- expires_at, last_login
## Development
### Project Structure
```
influence/
├── app/
│ ├── controllers/ # Business logic
│ ├── routes/ # API routes
│ ├── services/ # External integrations
│ ├── utils/ # Helper functions
│ ├── middleware/ # Express middleware
│ ├── public/ # Frontend assets
│ └── server.js # Express app entry point
├── scripts/
│ └── build-nocodb.sh # Database setup
├── docker-compose.yml # Container orchestration
├── Dockerfile # Container definition
└── .env # Environment configuration
```
### Key Components
- **RepresentativesController**: Handles postal code lookups and caching
- **EmailController**: Manages email composition, sending, and testing
- **NocoDBService**: Database operations with error handling
- **RepresentAPI**: Integration with OpenNorth Represent API
- **EmailService**: SMTP email functionality with test mode support
- **Email Testing System**: Preview, test, and log email functionality for development
## Features in Detail
### Smart Caching System
- First request fetches from Represent API and caches in NocoDB
- Subsequent requests served from cache for fast performance
- Graceful fallback to API when NocoDB is unavailable
- Automatic error recovery and retry logic
### Representative Display
- Shows photo with fallback to initials
- Contact information including phone and address
- Party affiliation and government level
- Direct links to official profiles
### Campaign System
- **Campaign Creation**: Create advocacy campaigns with custom titles, descriptions, and email templates
- **Cover Photos**: Upload hero images for campaign landing pages (JPEG/PNG/GIF/WebP, max 5MB)
- **Flexible Email Methods**: Choose between SMTP email or mailto links for user convenience
- **User Info Collection**: Optional name/email collection for campaign tracking
- **Email Count Display**: Show total engagement metrics on campaign pages (toggle on/off)
- **Email Editing**: Allow users to customize campaign email templates (optional)
- **Target Levels**: Select which government levels to target (Federal/Provincial/Municipal/School Board)
- **Campaign Status**: Draft, Active, Paused, or Archived workflow states
### Response Wall Feature
The Response Wall creates transparency and accountability by allowing campaign participants to share responses they receive from elected representatives.
**Key Features:**
- **Public Response Sharing**: Constituents can post responses received via email, letter, phone, meetings, or social media
- **Community Voting**: Upvote system highlights helpful and representative responses
- **Verification System**: Admin-moderated verification badges for authentic responses
- **Screenshot Support**: Upload visual proof of responses (images up to 5MB)
- **Anonymous Posting**: Option to share responses without revealing identity
- **Filtering & Sorting**: Filter by government level, sort by recent/upvotes/verified
- **Engagement Statistics**: Track total responses, verified count, and community upvotes
- **Moderation Queue**: Admin panel for approving, rejecting, or verifying submitted responses
- **Campaign Integration**: Response walls linked to specific campaigns for contextualized feedback
**Access Response Wall:**
- Via campaign page: Add `?campaign=your-campaign-slug` parameter to `/response-wall.html`
- Admin moderation: Navigate to "Response Moderation" tab in admin panel
- Public viewing: All approved responses visible to encourage participation
**Moderation Workflow:**
1. Users submit responses with required details (representative name, level, type, response text)
2. Submissions enter "pending" status in moderation queue
3. Admins review and approve/reject from admin panel
4. Approved responses appear on public Response Wall
5. Admins can mark verified responses with special badge
6. Community upvotes highlight most impactful responses
### QR Code Sharing Feature
The application includes dynamic QR code generation for easy campaign and response wall sharing.
**Key Features:**
- **Campaign QR Codes**: Generate scannable QR codes for campaign pages
- **Response Wall QR Codes**: Share response walls with QR codes for mobile scanning
- **High-Quality Generation**: 400x400px PNG images with high error correction (level H)
- **Download Support**: One-click download of QR code images for printing or sharing
- **Social Integration**: QR code button alongside social share buttons (Facebook, Twitter, LinkedIn, etc.)
- **Caching**: QR codes cached for 1 hour to improve performance
**How to Use:**
1. Visit any campaign page or response wall
2. Click the QR code icon (📱) in the social share buttons section
3. A modal appears with the generated QR code
4. Scan with any smartphone camera to visit the page
5. Click "Download QR Code" to save the image for printing or sharing
**Technical Implementation:**
- Backend endpoint: `GET /api/campaigns/:slug/qrcode?type=campaign|response-wall`
- Uses `qrcode` npm package for generation
- Proper MIME type and cache headers
- Modal UI with download functionality
**Use Cases:**
- Print QR codes on flyers and posters for offline campaign promotion
- Share QR codes in presentations and meetings
- Include in email newsletters for mobile-friendly access
- Display at events for easy sign-up
### Email Integration
- Modal-based email composer
- Pre-filled recipient information
- SMTP sending with delivery confirmation
- Email history and logging
### Error Handling
- Comprehensive error logging
- User-friendly error messages
- API fallback mechanisms
- Rate limiting protection
## Production Deployment
### Docker Production
```bash
# Build and start in production mode
docker compose -f docker-compose.yml up -d --build
# View logs
docker compose logs -f app
# Scale if needed
docker compose up --scale app=2
```
### Monitoring
- Health check endpoint: `/api/health`
- Application logs via Docker
- NocoDB integration status monitoring
- Email delivery tracking
## Troubleshooting
### Common Issues
1. **NocoDB Connection Errors**:
- Check API URL and token in .env
- Run `./scripts/build-nocodb.sh` to setup tables
- Application works without NocoDB (API fallback)
2. **Email Not Sending**:
- Verify SMTP credentials in .env
- Check spam/junk folders
- Review email logs via API endpoint
- In development: Check MailHog UI at http://localhost:8025
- Use email testing interface at `/email-test.html` for diagnostics
3. **No Representatives Found**:
- Ensure postal code starts with 'T' (Alberta)
- Check Represent API status
- Try different postal code format
### Log Analysis
```bash
# View application logs
docker compose logs app
# Follow logs in real-time
docker compose logs -f app
# Check specific errors
docker compose logs app | grep ERROR
```
## Contributing
This is part of the larger changemaker.lite project. Follow the established patterns for:
- Error handling and logging
- API response formats
- Database integration
- Frontend component structure
## License
Part of the changemaker.lite project ecosystem.

21
influence/app/Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM node:18-alpine
WORKDIR /usr/src/app
# Install curl for healthcheck
RUN apk add --no-cache curl
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install --only=production
# Copy app files
COPY . .
# Expose port
EXPOSE 3000
# Start the application
CMD ["node", "server.js"]

View File

@ -0,0 +1,264 @@
const nocodbService = require('../services/nocodb');
class AuthController {
async login(req, res) {
try {
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({
success: false,
error: 'Email and password are required'
});
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
error: 'Invalid email format'
});
}
console.log('Login attempt:', {
email,
ip: req.ip,
userAgent: req.headers['user-agent']
});
// Fetch user from NocoDB
const user = await nocodbService.getUserByEmail(email);
if (!user) {
console.warn(`No user found with email: ${email}`);
return res.status(401).json({
success: false,
error: 'Invalid email or password'
});
}
// Check password
if (user.Password !== password && user.password !== password) {
console.warn(`Invalid password for email: ${email}`);
return res.status(401).json({
success: false,
error: 'Invalid email or password'
});
}
// Check if temp user has expired
const userType = user['User Type'] || user.UserType || user.userType || 'user';
if (userType === 'temp') {
const expiration = user.ExpiresAt || user.expiresAt || user.Expiration || user.expiration;
if (expiration) {
const expirationDate = new Date(expiration);
const now = new Date();
if (now > expirationDate) {
console.warn(`Expired temp user attempted login: ${email}, expired: ${expiration}`);
return res.status(401).json({
success: false,
error: 'Account has expired. Please contact an administrator.'
});
}
}
}
// Update last login time
try {
// Debug: Log user object structure
console.log('User object keys:', Object.keys(user));
console.log('User ID candidates:', {
ID: user.ID,
Id: user.Id,
id: user.id
});
const userId = user.ID || user.Id || user.id;
if (userId) {
await nocodbService.updateUser(userId, {
'Last Login': new Date().toISOString()
});
} else {
console.warn('No valid user ID found for updating last login time');
}
} catch (updateError) {
console.warn('Failed to update last login time:', updateError.message);
// Don't fail the login
}
// Set session
req.session.authenticated = true;
req.session.userId = user.ID || user.Id || user.id;
req.session.userEmail = user.Email || user.email;
req.session.userName = user.Name || user.name;
req.session.isAdmin = user.Admin || user.admin || false;
req.session.userType = userType;
console.log('User logged in successfully:', {
email: req.session.userEmail,
isAdmin: req.session.isAdmin
});
// Force session save
req.session.save((err) => {
if (err) {
console.error('Session save error:', err);
return res.status(500).json({
success: false,
error: 'Session error. Please try again.'
});
}
res.json({
success: true,
user: {
id: req.session.userId,
email: req.session.userEmail,
name: req.session.userName,
isAdmin: req.session.isAdmin,
userType: req.session.userType
}
});
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
success: false,
error: 'Server error. Please try again later.'
});
}
}
async logout(req, res) {
try {
const userEmail = req.session?.userEmail;
req.session.destroy((err) => {
if (err) {
console.error('Session destroy error:', err);
return res.status(500).json({
success: false,
error: 'Logout failed'
});
}
console.log('User logged out:', userEmail);
res.json({ success: true });
});
} catch (error) {
console.error('Logout error:', error);
res.status(500).json({
success: false,
error: 'Server error during logout'
});
}
}
async checkSession(req, res) {
try {
const isAuthenticated = (req.session && req.session.authenticated) ||
(req.session && req.session.userId && req.session.userEmail);
if (isAuthenticated) {
res.json({
authenticated: true,
user: {
id: req.session.userId,
email: req.session.userEmail,
name: req.session.userName,
isAdmin: req.session.isAdmin,
userType: req.session.userType || 'user'
}
});
} else {
res.json({
authenticated: false
});
}
} catch (error) {
console.error('Session check error:', error);
res.status(500).json({
success: false,
error: 'Session check failed'
});
}
}
async changePassword(req, res) {
try {
const { currentPassword, newPassword } = req.body;
// Validate input
if (!currentPassword || !newPassword) {
return res.status(400).json({
success: false,
error: 'Current password and new password are required'
});
}
// Validate new password strength
if (newPassword.length < 8) {
return res.status(400).json({
success: false,
error: 'New password must be at least 8 characters long'
});
}
// Get user from session
const userId = req.session.userId;
const userEmail = req.session.userEmail;
if (!userId || !userEmail) {
return res.status(401).json({
success: false,
error: 'Session expired. Please login again.'
});
}
// Fetch user from NocoDB to verify current password
const user = await nocodbService.getUserByEmail(userEmail);
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
// Verify current password
const storedPassword = user.Password || user.password;
if (storedPassword !== currentPassword) {
return res.status(401).json({
success: false,
error: 'Current password is incorrect'
});
}
// Update password in NocoDB
await nocodbService.updateUser(userId, {
Password: newPassword
});
console.log('Password changed successfully for user:', userEmail);
res.json({
success: true,
message: 'Password changed successfully'
});
} catch (error) {
console.error('Change password error:', error);
res.status(500).json({
success: false,
error: 'Failed to change password. Please try again later.'
});
}
}
}
module.exports = new AuthController();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,283 @@
const nocoDB = require('../services/nocodb');
const { validateEmail } = require('../utils/validators');
class CustomRecipientsController {
/**
* Get all custom recipients for a campaign
*/
async getRecipientsByCampaign(req, res, next) {
try {
const { slug } = req.params;
// Get campaign first to verify it exists and get ID
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Check if custom recipients are enabled for this campaign
// Use NocoDB column title, not camelCase
if (!campaign['Allow Custom Recipients']) {
return res.json({ recipients: [], message: 'Custom recipients not enabled for this campaign' });
}
// Get custom recipients for this campaign using slug
const recipients = await nocoDB.getCustomRecipientsBySlug(slug);
res.json({
success: true,
recipients: recipients || [],
count: recipients ? recipients.length : 0
});
} catch (error) {
console.error('Error fetching custom recipients:', error);
next(error);
}
}
/**
* Create a single custom recipient
*/
async createRecipient(req, res, next) {
try {
const { slug } = req.params;
const { recipient_name, recipient_email, recipient_title, recipient_organization, notes } = req.body;
// Validate required fields
if (!recipient_name || !recipient_email) {
return res.status(400).json({ error: 'Recipient name and email are required' });
}
// Validate email format
if (!validateEmail(recipient_email)) {
return res.status(400).json({ error: 'Invalid email format' });
}
// Get campaign to verify it exists and get ID
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Check if custom recipients are enabled for this campaign
// Use NocoDB column title, not camelCase field name
if (!campaign['Allow Custom Recipients']) {
console.warn('Custom recipients not enabled. Campaign data:', campaign);
return res.status(403).json({ error: 'Custom recipients not enabled for this campaign' });
}
// Create the recipient
// Use campaign.ID (NocoDB system field) not campaign.id
const recipientData = {
campaign_id: campaign.ID,
campaign_slug: slug,
recipient_name,
recipient_email,
recipient_title: recipient_title || null,
recipient_organization: recipient_organization || null,
notes: notes || null,
is_active: true
};
const newRecipient = await nocoDB.createCustomRecipient(recipientData);
res.status(201).json({
success: true,
recipient: newRecipient,
message: 'Recipient created successfully'
});
} catch (error) {
console.error('Error creating custom recipient:', error);
next(error);
}
}
/**
* Bulk create custom recipients
*/
async bulkCreateRecipients(req, res, next) {
try {
const { slug } = req.params;
const { recipients } = req.body;
// Validate input
if (!Array.isArray(recipients) || recipients.length === 0) {
return res.status(400).json({ error: 'Recipients array is required and must not be empty' });
}
// Get campaign to verify it exists and get ID
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Check if custom recipients are enabled for this campaign
if (!campaign.allow_custom_recipients) {
return res.status(403).json({ error: 'Custom recipients not enabled for this campaign' });
}
const results = {
success: [],
failed: [],
total: recipients.length
};
// Process each recipient
for (const recipient of recipients) {
try {
// Validate required fields
if (!recipient.recipient_name || !recipient.recipient_email) {
results.failed.push({
recipient,
error: 'Missing required fields (name or email)'
});
continue;
}
// Validate email format
if (!validateEmail(recipient.recipient_email)) {
results.failed.push({
recipient,
error: 'Invalid email format'
});
continue;
}
// Create the recipient
const recipientData = {
campaign_id: campaign.id,
campaign_slug: slug,
recipient_name: recipient.recipient_name,
recipient_email: recipient.recipient_email,
recipient_title: recipient.recipient_title || null,
recipient_organization: recipient.recipient_organization || null,
notes: recipient.notes || null,
is_active: true
};
const newRecipient = await nocoDB.createCustomRecipient(recipientData);
results.success.push(newRecipient);
} catch (error) {
results.failed.push({
recipient,
error: error.message || 'Unknown error'
});
}
}
res.status(201).json({
success: true,
results,
message: `Successfully created ${results.success.length} of ${results.total} recipients`
});
} catch (error) {
console.error('Error bulk creating custom recipients:', error);
next(error);
}
}
/**
* Update a custom recipient
*/
async updateRecipient(req, res, next) {
try {
const { slug, id } = req.params;
const { recipient_name, recipient_email, recipient_title, recipient_organization, notes, is_active } = req.body;
// Validate email if provided
if (recipient_email && !validateEmail(recipient_email)) {
return res.status(400).json({ error: 'Invalid email format' });
}
// Get campaign to verify it exists
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Build update data (only include provided fields)
const updateData = {};
if (recipient_name !== undefined) updateData.recipient_name = recipient_name;
if (recipient_email !== undefined) updateData.recipient_email = recipient_email;
if (recipient_title !== undefined) updateData.recipient_title = recipient_title;
if (recipient_organization !== undefined) updateData.recipient_organization = recipient_organization;
if (notes !== undefined) updateData.notes = notes;
if (is_active !== undefined) updateData.is_active = is_active;
// Update the recipient
const updatedRecipient = await nocoDB.updateCustomRecipient(id, updateData);
if (!updatedRecipient) {
return res.status(404).json({ error: 'Recipient not found' });
}
res.json({
success: true,
recipient: updatedRecipient,
message: 'Recipient updated successfully'
});
} catch (error) {
console.error('Error updating custom recipient:', error);
next(error);
}
}
/**
* Delete a custom recipient
*/
async deleteRecipient(req, res, next) {
try {
const { slug, id } = req.params;
// Get campaign to verify it exists
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Delete the recipient
const deleted = await nocoDB.deleteCustomRecipient(id);
if (!deleted) {
return res.status(404).json({ error: 'Recipient not found' });
}
res.json({
success: true,
message: 'Recipient deleted successfully'
});
} catch (error) {
console.error('Error deleting custom recipient:', error);
next(error);
}
}
/**
* Delete all custom recipients for a campaign
*/
async deleteAllRecipients(req, res, next) {
try {
const { slug } = req.params;
// Get campaign to verify it exists and get ID
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Delete all recipients for this campaign
const deletedCount = await nocoDB.deleteCustomRecipientsByCampaign(campaign.id);
res.json({
success: true,
deletedCount,
message: `Successfully deleted ${deletedCount} recipient(s)`
});
} catch (error) {
console.error('Error deleting all custom recipients:', error);
next(error);
}
}
}
module.exports = new CustomRecipientsController();

View File

@ -0,0 +1,417 @@
const emailService = require('../services/email');
const nocoDB = require('../services/nocodb');
const crypto = require('crypto');
class EmailsController {
async sendEmail(req, res, next) {
try {
const { recipientEmail, senderName, senderEmail, subject, message, postalCode, recipientName } = req.body;
// Send the email using template system
const emailResult = await emailService.sendRepresentativeEmail(
recipientEmail,
senderName,
senderEmail,
subject,
message,
postalCode,
recipientName
);
// Log the email send event
await nocoDB.logEmailSend({
recipientEmail,
senderName,
senderEmail,
subject,
message,
postalCode,
status: emailResult.success ? 'sent' : 'failed',
timestamp: new Date().toISOString(),
senderIP: req.ip || req.connection.remoteAddress
});
if (emailResult.success) {
res.json({
success: true,
message: 'Email sent successfully',
messageId: emailResult.messageId
});
} else {
res.status(500).json({
success: false,
error: 'Failed to send email',
message: emailResult.error
});
}
} catch (error) {
console.error('Send email error:', error);
res.status(500).json({
success: false,
error: 'Failed to send email',
message: error.message
});
}
}
async previewEmail(req, res, next) {
try {
const { recipientEmail, subject, message, senderName, senderEmail, postalCode, recipientName } = req.body;
const templateVariables = {
MESSAGE: message,
SENDER_NAME: senderName || 'Anonymous',
SENDER_EMAIL: senderEmail || 'unknown@example.com',
POSTAL_CODE: postalCode || 'Unknown',
RECIPIENT_NAME: recipientName || 'Representative'
};
const emailOptions = {
to: recipientEmail,
from: {
email: process.env.SMTP_FROM_EMAIL,
name: process.env.SMTP_FROM_NAME
},
replyTo: senderEmail || process.env.SMTP_FROM_EMAIL,
subject: subject
};
const preview = await emailService.previewTemplatedEmail('representative-contact', templateVariables, emailOptions);
// Log the email preview event (non-blocking)
try {
await nocoDB.logEmailPreview({
recipientEmail,
senderName,
senderEmail,
subject,
message,
postalCode,
timestamp: new Date().toISOString(),
senderIP: req.ip || req.connection.remoteAddress
});
} catch (loggingError) {
console.error('Failed to log email preview:', loggingError);
// Don't fail the preview if logging fails
}
res.json({
success: true,
preview: preview,
html: emailOptions.html
});
} catch (error) {
console.error('Email preview error:', error);
res.status(500).json({
success: false,
error: 'Failed to generate email preview',
message: error.message
});
}
}
async sendTestEmail(req, res, next) {
try {
const { subject, message } = req.body;
const testRecipient = process.env.TEST_EMAIL_RECIPIENT || req.user?.email || 'admin@example.com';
const emailResult = await emailService.sendTestEmail(subject, message, testRecipient);
if (emailResult.success) {
res.json({
success: true,
message: 'Test email sent successfully',
messageId: emailResult.messageId,
sentTo: testRecipient,
testMode: emailResult.testMode
});
} else {
res.status(500).json({
success: false,
error: 'Failed to send test email',
message: emailResult.error
});
}
} catch (error) {
console.error('Send test email error:', error);
res.status(500).json({
success: false,
error: 'Failed to send test email',
message: error.message
});
}
}
async getEmailLogs(req, res, next) {
try {
const { status, senderEmail, postalCode } = req.query;
if (!process.env.NOCODB_TABLE_EMAILS) {
return res.status(500).json({
success: false,
error: 'Email logging not configured'
});
}
const filters = {};
if (status) filters.status = status;
if (senderEmail) filters.senderEmail = senderEmail;
if (postalCode) filters.postalCode = postalCode;
const logs = await nocoDB.getEmailLogs(filters);
res.json({
success: true,
logs: logs || []
});
} catch (error) {
console.error('Get email logs error:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve email logs',
message: error.message
});
}
}
async testSMTPConnection(req, res, next) {
try {
const testResult = await emailService.testConnection();
res.json({
success: testResult.success,
message: testResult.message,
error: testResult.error
});
} catch (error) {
console.error('SMTP test error:', error);
res.status(500).json({
success: false,
error: 'Failed to test SMTP connection',
message: error.message
});
}
}
async testSMTPConnection(req, res, next) {
try {
const result = await emailService.testConnection();
res.json({
success: result.success,
message: result.message,
error: result.error
});
} catch (error) {
console.error('SMTP test error:', error);
res.status(500).json({
success: false,
error: 'Failed to test SMTP connection',
message: error.message
});
}
}
async initiateEmailToCampaign(req, res, next) {
try {
const { email, subject, message, postalCode, senderName } = req.body;
// Check if email verification is enabled
const verificationEnabled = process.env.EMAIL_VERIFICATION_ENABLED !== 'false';
if (!verificationEnabled) {
return res.status(400).json({
success: false,
error: 'Email verification is not enabled'
});
}
// Generate verification token
const token = crypto.randomBytes(32).toString('hex');
const expiryHours = parseInt(process.env.EMAIL_VERIFICATION_EXPIRY) || 24;
const expiresAt = new Date(Date.now() + expiryHours * 60 * 60 * 1000);
// Store token and campaign data
await nocoDB.createEmailVerification({
token,
email,
temp_campaign_data: JSON.stringify({
subject,
message,
postalCode,
senderName
}),
created_at: new Date().toISOString(),
expires_at: expiresAt.toISOString(),
used: false
});
// Send verification email
const appUrl = process.env.APP_URL || 'http://localhost:3333';
const verificationUrl = `${appUrl}/verify-email.html?token=${token}`;
await emailService.sendEmailVerification(email, verificationUrl, senderName || 'there');
res.json({
success: true,
message: 'Verification email sent. Please check your inbox.'
});
} catch (error) {
console.error('Email to campaign conversion error:', error);
res.status(500).json({
success: false,
error: 'Failed to initiate campaign conversion',
message: error.message
});
}
}
async verifyEmailToken(req, res, next) {
try {
const { token } = req.params;
// Find verification record
const verification = await nocoDB.getEmailVerificationByToken(token);
if (!verification) {
return res.status(404).json({
success: false,
error: 'Invalid or expired verification link'
});
}
// Check if already used
if (verification.used) {
return res.status(400).json({
success: false,
error: 'This verification link has already been used'
});
}
// Check if expired
const now = new Date();
const expiresAt = new Date(verification.expires_at || verification.expiresAt || verification['Expires At']);
if (now > expiresAt) {
return res.status(400).json({
success: false,
error: 'This verification link has expired'
});
}
// Mark as used
const verificationId = verification.id || verification.Id || verification.ID;
await nocoDB.updateEmailVerification(verificationId, { used: true });
// Parse campaign data
const campaignDataStr = verification.temp_campaign_data || verification.tempCampaignData || verification['Temp Campaign Data'];
const campaignData = JSON.parse(campaignDataStr);
// Check if user exists
const userEmail = verification.email || verification.Email;
const existingUser = await nocoDB.getUserByEmail(userEmail);
if (existingUser) {
// User exists, log them in automatically
req.session.authenticated = true;
req.session.userId = existingUser.ID || existingUser.Id || existingUser.id;
req.session.userEmail = existingUser.Email || existingUser.email;
req.session.userName = existingUser.Name || existingUser.name;
req.session.isAdmin = existingUser.Admin || existingUser.admin || false;
req.session.userType = existingUser['User Type'] || existingUser.UserType || existingUser.userType || 'user';
req.session.save((err) => {
if (err) {
console.error('Session save error:', err);
return res.status(500).json({
success: false,
error: 'Session error'
});
}
res.json({
success: true,
needsAccount: false,
campaignData: campaignData,
redirectTo: '/dashboard.html'
});
});
} else {
// User doesn't exist - create a new user account automatically
try {
// Generate a temporary password (user can change it later)
const tempPassword = crypto.randomBytes(16).toString('hex');
// Extract name from campaign data or use email prefix
const userName = campaignData.senderName || userEmail.split('@')[0];
// Create new user
const newUser = await nocoDB.createUser({
'Name': userName,
'Email': userEmail,
'Password': tempPassword,
'Admin': false,
'User Type': 'user'
});
const userId = newUser.ID || newUser.Id || newUser.id || newUser;
// Send login credentials email to the new user
try {
await emailService.sendLoginDetails({
Name: userName,
Email: userEmail,
Password: tempPassword,
admin: false
});
console.log('Welcome email with credentials sent to:', userEmail);
} catch (emailError) {
console.error('Failed to send welcome email:', emailError);
// Don't fail the whole process if email sending fails
}
// Log the new user in automatically
req.session.authenticated = true;
req.session.userId = userId;
req.session.userEmail = userEmail;
req.session.userName = userName;
req.session.isAdmin = false;
req.session.userType = 'user';
req.session.save((err) => {
if (err) {
console.error('Session save error:', err);
return res.status(500).json({
success: false,
error: 'Session error'
});
}
res.json({
success: true,
needsAccount: false,
isNewUser: true,
campaignData: campaignData,
redirectTo: '/dashboard.html',
message: 'Account created successfully! Check your email for login credentials.'
});
});
} catch (createError) {
console.error('Error creating user account:', createError);
return res.status(500).json({
success: false,
error: 'Failed to create user account',
message: createError.message
});
}
}
} catch (error) {
console.error('Email verification error:', error);
res.status(500).json({
success: false,
error: 'Failed to verify email',
message: error.message
});
}
}
}
module.exports = new EmailsController();

View File

@ -0,0 +1,262 @@
const listmonkService = require('../services/listmonk');
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
// Get Listmonk sync status
exports.getSyncStatus = async (req, res) => {
try {
const status = listmonkService.getSyncStatus();
// Also check connection if it's enabled
if (status.enabled && !status.connected) {
// Try to reconnect
const reconnected = await listmonkService.checkConnection();
status.connected = reconnected;
}
res.json(status);
} catch (error) {
logger.error('Failed to get Listmonk status', error);
res.status(500).json({
success: false,
error: 'Failed to get sync status'
});
}
};
// Sync all campaign participants to Listmonk
exports.syncCampaignParticipants = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
// Get all campaign emails (use campaignEmails table, not emails)
const emailsData = await nocodbService.getAll(nocodbService.tableIds.campaignEmails);
const emails = emailsData?.list || [];
// Get all campaigns for reference
const campaigns = await nocodbService.getAllCampaigns();
if (!emails || emails.length === 0) {
return res.json({
success: true,
message: 'No campaign participants to sync',
results: { total: 0, success: 0, failed: 0, errors: [] }
});
}
const results = await listmonkService.bulkSyncCampaignParticipants(emails, campaigns);
res.json({
success: true,
message: `Campaign participants sync completed: ${results.success} succeeded, ${results.failed} failed`,
results
});
} catch (error) {
logger.error('Campaign participants sync failed', error);
res.status(500).json({
success: false,
error: 'Failed to sync campaign participants to Listmonk'
});
}
};
// Sync all custom recipients to Listmonk
exports.syncCustomRecipients = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
// Get all custom recipients
const recipientsData = await nocodbService.getAll(nocodbService.tableIds.customRecipients);
const recipients = recipientsData?.list || [];
// Get all campaigns for reference
const campaigns = await nocodbService.getAllCampaigns();
if (!recipients || recipients.length === 0) {
return res.json({
success: true,
message: 'No custom recipients to sync',
results: { total: 0, success: 0, failed: 0, errors: [] }
});
}
const results = await listmonkService.bulkSyncCustomRecipients(recipients, campaigns);
res.json({
success: true,
message: `Custom recipients sync completed: ${results.success} succeeded, ${results.failed} failed`,
results
});
} catch (error) {
logger.error('Custom recipients sync failed', error);
res.status(500).json({
success: false,
error: 'Failed to sync custom recipients to Listmonk'
});
}
};
// Sync everything (participants and custom recipients)
exports.syncAll = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
let results = {
participants: { total: 0, success: 0, failed: 0, errors: [] },
customRecipients: { total: 0, success: 0, failed: 0, errors: [] }
};
// Get campaigns once for both syncs
const campaigns = await nocodbService.getAllCampaigns();
// Sync campaign participants
try {
const emailsData = await nocodbService.getAll(nocodbService.tableIds.campaignEmails);
const emails = emailsData?.list || [];
if (emails && emails.length > 0) {
results.participants = await listmonkService.bulkSyncCampaignParticipants(emails, campaigns);
}
} catch (error) {
logger.error('Failed to sync campaign participants during full sync', error);
results.participants.errors.push({ error: error.message });
}
// Sync custom recipients
try {
const recipientsData = await nocodbService.getAll(nocodbService.tableIds.customRecipients);
const recipients = recipientsData?.list || [];
if (recipients && recipients.length > 0) {
results.customRecipients = await listmonkService.bulkSyncCustomRecipients(recipients, campaigns);
}
} catch (error) {
logger.error('Failed to sync custom recipients during full sync', error);
results.customRecipients.errors.push({ error: error.message });
}
const totalSuccess = results.participants.success + results.customRecipients.success;
const totalFailed = results.participants.failed + results.customRecipients.failed;
res.json({
success: true,
message: `Complete sync finished: ${totalSuccess} succeeded, ${totalFailed} failed`,
results
});
} catch (error) {
logger.error('Complete sync failed', error);
res.status(500).json({
success: false,
error: 'Failed to perform complete sync'
});
}
};
// Get Listmonk list statistics
exports.getListStats = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.json({
success: false,
error: 'Listmonk sync is disabled',
stats: null
});
}
const stats = await listmonkService.getListStats();
// Convert stats object to array format for frontend
let statsArray = [];
if (stats && typeof stats === 'object') {
statsArray = Object.entries(stats).map(([key, list]) => ({
id: key,
name: list.name,
subscriberCount: list.subscriber_count || 0,
description: `Email list for ${key}`
}));
}
res.json({
success: true,
stats: statsArray
});
} catch (error) {
logger.error('Failed to get Listmonk list stats', error);
res.status(500).json({
success: false,
error: 'Failed to get list statistics'
});
}
};
// Test Listmonk connection
exports.testConnection = async (req, res) => {
try {
const connected = await listmonkService.checkConnection();
if (connected) {
res.json({
success: true,
message: 'Listmonk connection successful',
connected: true
});
} else {
res.json({
success: false,
message: listmonkService.lastError || 'Connection failed',
connected: false
});
}
} catch (error) {
logger.error('Failed to test Listmonk connection', error);
res.status(500).json({
success: false,
error: 'Failed to test connection'
});
}
};
// Reinitialize Listmonk lists
exports.reinitializeLists = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
const initialized = await listmonkService.initializeLists();
if (initialized) {
res.json({
success: true,
message: 'Listmonk lists reinitialized successfully'
});
} else {
res.json({
success: false,
message: listmonkService.lastError || 'Failed to initialize lists'
});
}
} catch (error) {
logger.error('Failed to reinitialize Listmonk lists', error);
res.status(500).json({
success: false,
error: 'Failed to reinitialize lists'
});
}
};

View File

@ -0,0 +1,282 @@
const representAPI = require('../services/represent-api');
const nocoDB = require('../services/nocodb');
// Helper function to cache representatives
async function cacheRepresentatives(postalCode, representatives, representData) {
try {
// Cache the postal code info
await nocoDB.storePostalCodeInfo({
postal_code: postalCode,
city: representData.city,
province: representData.province
});
// Cache representatives using the existing method
const result = await nocoDB.storeRepresentatives(postalCode, representatives);
if (result.success) {
console.log(`Successfully cached ${result.count} representatives for ${postalCode}`);
} else {
console.log(`Partial success caching representatives for ${postalCode}: ${result.error || 'unknown error'}`);
}
} catch (error) {
console.log(`Failed to cache representatives for ${postalCode}:`, error.message);
// Don't throw - caching is optional and should never break the main flow
}
}
class RepresentativesController {
async testConnection(req, res, next) {
try {
const result = await representAPI.testConnection();
res.json(result);
} catch (error) {
console.error('Test connection error:', error);
res.status(500).json({
error: 'Failed to test connection',
message: error.message
});
}
}
async getByPostalCode(req, res, next) {
try {
const { postalCode } = req.params;
const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
// Try to check cached data first, but don't fail if NocoDB is down
let cachedData = [];
try {
cachedData = await nocoDB.getRepresentativesByPostalCode(formattedPostalCode);
console.log(`Cache check for ${formattedPostalCode}: found ${cachedData.length} records`);
if (cachedData && cachedData.length > 0) {
return res.json({
success: true,
source: 'Local Cache',
data: {
postalCode: formattedPostalCode,
location: {
city: cachedData[0]?.city || 'Alberta',
province: 'AB'
},
representatives: cachedData
}
});
}
} catch (cacheError) {
console.log(`Cache unavailable for ${formattedPostalCode}, proceeding with API call:`, cacheError.message);
}
// If not in cache, fetch from Represent API
console.log(`Fetching representatives from Represent API for ${postalCode}`);
const representData = await representAPI.getRepresentativesByPostalCode(postalCode);
if (!representData) {
return res.json({
success: false,
message: 'No data found for this postal code',
data: {
postalCode,
location: null,
representatives: []
}
});
}
// Process representatives from both concordance and centroid
let representatives = [];
// Add concordance representatives (if any)
if (representData.boundaries_concordance && representData.boundaries_concordance.length > 0) {
representatives = representatives.concat(representData.boundaries_concordance);
}
// Add centroid representatives (if any) - these are the actual elected officials
if (representData.representatives_centroid && representData.representatives_centroid.length > 0) {
representatives = representatives.concat(representData.representatives_centroid);
}
// Representatives already include office information, no need for additional API calls
console.log('Using representatives data with existing office information');
console.log(`Representatives concordance count: ${representData.boundaries_concordance ? representData.boundaries_concordance.length : 0}`);
console.log(`Representatives centroid count: ${representData.representatives_centroid ? representData.representatives_centroid.length : 0}`);
console.log(`Total representatives found: ${representatives.length}`);
if (representatives.length === 0) {
return res.json({
success: false,
message: 'No representatives found for this postal code',
data: {
postalCode,
location: {
city: representData.city,
province: representData.province
},
representatives: []
}
});
}
// Try to cache the results (will fail gracefully if NocoDB is down)
console.log(`Attempting to cache ${representatives.length} representatives for ${postalCode}`);
await cacheRepresentatives(postalCode, representatives, representData);
res.json({
success: true,
source: 'Open North',
data: {
postalCode,
location: {
city: representData.city,
province: representData.province
},
representatives
}
});
} catch (error) {
console.error('Get representatives error:', error);
res.status(500).json({
error: 'Failed to fetch representatives',
message: error.message
});
}
}
async refreshPostalCode(req, res, next) {
try {
const { postalCode } = req.params;
const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
// Clear cached data
await nocoDB.clearRepresentativesByPostalCode(formattedPostalCode);
// Fetch fresh data from API
const representData = await representAPI.getRepresentativesByPostalCode(formattedPostalCode);
if (!representData || !representData.representatives_concordance) {
return res.status(404).json({
error: 'No representatives found for this postal code',
postalCode: formattedPostalCode
});
}
// Cache the fresh results
await nocoDB.storeRepresentatives(formattedPostalCode, representData.representatives_concordance);
res.json({
source: 'Open North',
postalCode: formattedPostalCode,
representatives: representData.representatives_concordance,
city: representData.city,
province: representData.province
});
} catch (error) {
console.error('Refresh representatives error:', error);
res.status(500).json({
error: 'Failed to refresh representatives',
message: error.message
});
}
}
async trackCall(req, res, next) {
try {
const {
representativeName,
representativeTitle,
phoneNumber,
officeType,
userEmail,
userName,
postalCode
} = req.body;
// Validate required fields
if (!representativeName || !phoneNumber) {
return res.status(400).json({
success: false,
error: 'Representative name and phone number are required'
});
}
// Log the call
await nocoDB.logCall({
representativeName,
representativeTitle: representativeTitle || null,
phoneNumber,
officeType: officeType || null,
callerName: userName || null,
callerEmail: userEmail || null,
postalCode: postalCode || null,
campaignId: null,
campaignSlug: null,
callerIP: req.ip || req.connection?.remoteAddress || null,
timestamp: new Date().toISOString()
});
res.json({
success: true,
message: 'Call tracked successfully'
});
} catch (error) {
console.error('Track call error:', error);
res.status(500).json({
success: false,
error: 'Failed to track call',
message: error.message
});
}
}
async geocodeAddress(req, res, next) {
try {
const { address } = req.body;
const axios = require('axios');
console.log(`Geocoding address: ${address}`);
// Use Nominatim API (OpenStreetMap)
const encodedAddress = encodeURIComponent(address);
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodedAddress}&limit=1&countrycodes=ca`;
const response = await axios.get(url, {
headers: {
'User-Agent': 'BNKops-Influence-Tool/1.0'
},
timeout: 5000
});
if (response.data && response.data.length > 0 && response.data[0].lat && response.data[0].lon) {
const result = {
lat: parseFloat(response.data[0].lat),
lng: parseFloat(response.data[0].lon),
display_name: response.data[0].display_name
};
console.log(`Geocoded "${address}" to:`, result);
res.json({
success: true,
data: result
});
} else {
console.log(`No geocoding results for: ${address}`);
res.json({
success: false,
message: 'No results found for this address'
});
}
} catch (error) {
console.error('Geocoding error:', error);
res.status(500).json({
success: false,
error: 'Geocoding failed',
message: error.message
});
}
}
}
module.exports = new RepresentativesController();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,338 @@
const nocodbService = require('../services/nocodb');
const { sendLoginDetails } = require('../services/email');
const { sanitizeUser, extractId } = require('../utils/helpers');
class UsersController {
async getAll(req, res) {
try {
console.log('UsersController.getAll called');
console.log('Users table ID:', nocodbService.tableIds.users);
if (!nocodbService.tableIds.users) {
console.error('Users table not configured in environment');
return res.status(500).json({
success: false,
error: 'Users table not configured. Please set NOCODB_TABLE_USERS in your environment variables.'
});
}
console.log('Fetching users from NocoDB...');
const response = await nocodbService.getAll(nocodbService.tableIds.users, {
limit: 100
});
const users = response.list || [];
console.log(`Retrieved ${users.length} users from database`);
// Remove password field from response for security
const safeUsers = users.map(sanitizeUser);
res.json({
success: true,
users: safeUsers
});
} catch (error) {
console.error('Error fetching users:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch users: ' + error.message
});
}
}
async create(req, res) {
try {
const { email, password, name, phone, isAdmin, userType, expireDays } = req.body;
if (!email || !password) {
return res.status(400).json({
success: false,
error: 'Email and password are required'
});
}
if (!nocodbService.tableIds.users) {
return res.status(500).json({
success: false,
error: 'Users table not configured'
});
}
// Check if user already exists
console.log(`Checking if user exists with email: ${email}`);
let existingUser = null;
try {
existingUser = await nocodbService.getUserByEmail(email);
console.log('Existing user check result:', existingUser ? 'User exists' : 'User does not exist');
} catch (error) {
console.error('Error checking for existing user:', error.message);
// Continue with creation if check fails - NocoDB will handle the unique constraint
}
if (existingUser) {
console.log('Existing user found:', { id: existingUser.ID || existingUser.Id || existingUser.id, email: existingUser.Email || existingUser.email });
return res.status(400).json({
success: false,
error: 'User with this email already exists'
});
}
// Calculate expiration date for temp users
let expiresAt = null;
if (userType === 'temp' && expireDays) {
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + expireDays);
expiresAt = expirationDate.toISOString();
}
// Create new user - use the exact column titles from NocoDB schema
const userData = {
Email: email,
Name: name || '',
Password: password,
Phone: phone || '',
Admin: isAdmin === true,
'User Type': userType || 'user',
ExpiresAt: expiresAt,
ExpireDays: userType === 'temp' ? expireDays : null,
'Last Login': null
};
const response = await nocodbService.create(
nocodbService.tableIds.users,
userData
);
res.status(201).json({
success: true,
message: 'User created successfully',
user: {
id: extractId(response),
email: email,
name: name,
phone: phone,
admin: isAdmin,
userType: userType,
expiresAt: expiresAt
}
});
} catch (error) {
console.error('Error creating user:', error);
// Check if it's a unique constraint violation (email already exists)
if (error.response?.data?.code === '23505' ||
error.response?.data?.message?.includes('already exists') ||
error.message?.includes('already exists')) {
return res.status(400).json({
success: false,
error: 'A user with this email address already exists'
});
}
res.status(500).json({
success: false,
error: 'Failed to create user: ' + (error.message || 'Unknown error')
});
}
}
async delete(req, res) {
try {
const userId = req.params.id;
if (!nocodbService.tableIds.users) {
return res.status(500).json({
success: false,
error: 'Users table not configured'
});
}
// Don't allow admins to delete themselves
if (userId === req.session.userId) {
return res.status(400).json({
success: false,
error: 'Cannot delete your own account'
});
}
await nocodbService.deleteUser(userId);
res.json({
success: true,
message: 'User deleted successfully'
});
} catch (error) {
console.error('Error deleting user:', error);
res.status(500).json({
success: false,
error: 'Failed to delete user'
});
}
}
async sendLoginDetails(req, res) {
try {
const userId = req.params.id;
if (!nocodbService.tableIds.users) {
return res.status(500).json({
success: false,
error: 'Users table not configured'
});
}
// Get user data from database
const user = await nocodbService.getById(
nocodbService.tableIds.users,
userId
);
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
// Send login details email
await sendLoginDetails(user);
res.json({
success: true,
message: 'Login details sent successfully'
});
} catch (error) {
console.error('Error sending login details:', error);
res.status(500).json({
success: false,
error: 'Failed to send login details'
});
}
}
async emailAllUsers(req, res) {
try {
const { subject, content } = req.body;
if (!subject || !content) {
return res.status(400).json({
success: false,
error: 'Subject and content are required'
});
}
if (!nocodbService.tableIds.users) {
return res.status(500).json({
success: false,
error: 'Users table not configured'
});
}
// Get all users
const response = await nocodbService.getAll(nocodbService.tableIds.users, {
limit: 1000
});
const users = response.list || [];
if (users.length === 0) {
return res.status(400).json({
success: false,
error: 'No users found to email'
});
}
// Import email service
const { sendEmail } = require('../services/email');
const emailTemplates = require('../services/emailTemplates');
// Convert rich text content to plain text for the text version
const stripHtmlTags = (html) => {
return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
};
// Prepare base template variables
const baseTemplateVariables = {
APP_NAME: 'BNKops Influence - User Broadcast',
EMAIL_SUBJECT: subject,
EMAIL_CONTENT: content,
EMAIL_CONTENT_TEXT: stripHtmlTags(content),
SENDER_NAME: req.session.userName || req.session.userEmail || 'Administrator',
TIMESTAMP: new Date().toLocaleString()
};
// Send emails to all users
const emailResults = [];
const failedEmails = [];
for (const user of users) {
try {
const userVariables = {
...baseTemplateVariables,
USER_NAME: user.Name || user.name || user.Email || user.email || 'User',
USER_EMAIL: user.Email || user.email
};
const emailContent = await emailTemplates.render('user-broadcast', userVariables);
await sendEmail({
to: user.Email || user.email,
subject: subject,
text: emailContent.text,
html: emailContent.html
});
emailResults.push({
email: user.Email || user.email,
name: user.Name || user.name || user.Email || user.email,
success: true
});
console.log(`Sent broadcast email to: ${user.Email || user.email}`);
} catch (emailError) {
console.error(`Failed to send broadcast email to ${user.Email || user.email}:`, emailError);
failedEmails.push({
email: user.Email || user.email,
name: user.Name || user.name || user.Email || user.email,
error: emailError.message
});
}
}
const successCount = emailResults.length;
const failCount = failedEmails.length;
if (successCount === 0) {
return res.status(500).json({
success: false,
error: 'Failed to send any emails',
details: failedEmails
});
}
res.json({
success: true,
message: `Sent email to ${successCount} user${successCount !== 1 ? 's' : ''}${failCount > 0 ? `, ${failCount} failed` : ''}`,
results: {
successful: emailResults,
failed: failedEmails,
total: users.length,
subject: subject
}
});
} catch (error) {
console.error('Error sending broadcast email:', error);
res.status(500).json({
success: false,
error: 'Failed to send broadcast email'
});
}
}
}
module.exports = new UsersController();

View File

@ -0,0 +1,196 @@
const nocodbService = require('../services/nocodb');
// Helper function to check if a temp user has expired
const checkTempUserExpiration = async (req, res) => {
if (req.session?.userType === 'temp' && req.session?.userEmail) {
try {
const user = await nocodbService.getUserByEmail(req.session.userEmail);
if (user) {
const expiration = user.ExpiresAt || user.expiresAt || user.Expiration || user.expiration;
if (expiration) {
const expirationDate = new Date(expiration);
const now = new Date();
if (now > expirationDate) {
console.warn(`Expired temp user session detected: ${req.session.userEmail}, expired: ${expiration}`);
// Destroy the session
req.session.destroy((err) => {
if (err) {
console.error('Session destroy error:', err);
}
});
if (req.xhr || req.headers.accept?.indexOf('json') > -1) {
return res.status(401).json({
success: false,
error: 'Account has expired. Please contact an administrator.',
expired: true
});
} else {
return res.redirect('/login.html?expired=true');
}
}
}
}
} catch (error) {
console.error('Error checking temp user expiration:', error.message);
// Don't fail the request on database errors, just log it
}
}
return null; // No expiration issue
};
const requireAuth = async (req, res, next) => {
const isAuthenticated = (req.session && req.session.authenticated) ||
(req.session && req.session.userId && req.session.userEmail);
if (isAuthenticated) {
// Check if temp user has expired
const expirationResponse = await checkTempUserExpiration(req, res);
if (expirationResponse) {
return; // Response already sent by checkTempUserExpiration
}
// Set up req.user object for controllers that expect it
req.user = {
id: req.session.userId,
email: req.session.userEmail,
isAdmin: req.session.isAdmin || false,
userType: req.session.userType || 'user',
name: req.session.userName || req.session.user_name || null
};
next();
} else {
console.warn('Unauthorized access attempt', {
ip: req.ip,
path: req.path,
userAgent: req.get('User-Agent'),
method: req.method,
timestamp: new Date().toISOString()
});
if (req.xhr || req.headers.accept?.indexOf('json') > -1) {
res.status(401).json({
success: false,
error: 'Authentication required'
});
} else {
res.redirect('/login.html');
}
}
};
const requireAdmin = async (req, res, next) => {
const isAuthenticated = (req.session && req.session.authenticated) ||
(req.session && req.session.userId && req.session.userEmail);
if (isAuthenticated && req.session.isAdmin) {
// Check if temp user has expired
const expirationResponse = await checkTempUserExpiration(req, res);
if (expirationResponse) {
return; // Response already sent by checkTempUserExpiration
}
// Set up req.user object for controllers that expect it
req.user = {
id: req.session.userId,
email: req.session.userEmail,
isAdmin: req.session.isAdmin || false,
userType: req.session.userType || 'user',
name: req.session.userName || req.session.user_name || null
};
next();
} else {
console.warn('Unauthorized admin access attempt', {
ip: req.ip,
path: req.path,
user: req.session?.userEmail || 'anonymous',
userAgent: req.get('User-Agent')
});
if (req.xhr || req.headers.accept?.indexOf('json') > -1) {
res.status(403).json({
success: false,
error: 'Admin access required'
});
} else {
res.redirect('/login.html');
}
}
};
const requireNonTemp = async (req, res, next) => {
const isAuthenticated = (req.session && req.session.authenticated) ||
(req.session && req.session.userId && req.session.userEmail);
if (isAuthenticated && req.session.userType !== 'temp') {
// Check if temp user has expired (shouldn't happen here, but for safety)
const expirationResponse = await checkTempUserExpiration(req, res);
if (expirationResponse) {
return; // Response already sent by checkTempUserExpiration
}
// Set up req.user object for controllers that expect it
req.user = {
id: req.session.userId,
email: req.session.userEmail,
isAdmin: req.session.isAdmin || false,
userType: req.session.userType || 'user',
name: req.session.userName || req.session.user_name || null
};
next();
} else {
console.warn('Temp user access denied', {
ip: req.ip,
path: req.path,
user: req.session?.userEmail || 'anonymous',
userType: req.session?.userType || 'unknown'
});
if (req.xhr || req.headers.accept?.indexOf('json') > -1) {
res.status(403).json({
success: false,
error: 'Access denied for temporary users'
});
} else {
res.redirect('/');
}
}
};
// Optional authentication - sets req.user if authenticated, but doesn't block if not
const optionalAuth = async (req, res, next) => {
const isAuthenticated = (req.session && req.session.authenticated) ||
(req.session && req.session.userId && req.session.userEmail);
if (isAuthenticated) {
// Check if temp user has expired
const expirationResponse = await checkTempUserExpiration(req, res);
if (expirationResponse) {
return; // Response already sent by checkTempUserExpiration
}
// Set up req.user object for controllers that expect it
req.user = {
id: req.session.userId,
email: req.session.userEmail,
isAdmin: req.session.isAdmin || false,
userType: req.session.userType || 'user',
name: req.session.userName || req.session.user_name || null
};
}
// Continue regardless of authentication status
next();
};
module.exports = {
requireAuth,
requireAdmin,
requireNonTemp,
optionalAuth
};

View File

@ -0,0 +1,142 @@
const csrf = require('csurf');
const logger = require('../utils/logger');
// Create CSRF protection middleware
const csrfProtection = csrf({
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true',
sameSite: 'strict',
maxAge: 3600000 // 1 hour
}
});
/**
* Middleware to handle CSRF token errors
*/
const csrfErrorHandler = (err, req, res, next) => {
if (err.code === 'EBADCSRFTOKEN') {
logger.warn('CSRF token validation failed', {
ip: req.ip,
path: req.path,
method: req.method,
userAgent: req.get('user-agent')
});
return res.status(403).json({
success: false,
error: 'Invalid CSRF token',
message: 'Your session has expired or the request is invalid. Please refresh the page and try again.'
});
}
next(err);
};
/**
* Middleware to inject CSRF token into response
* Adds csrfToken to all JSON responses and as a header
*/
const injectCsrfToken = (req, res, next) => {
// Add token to response locals for template rendering
res.locals.csrfToken = req.csrfToken();
// Override json method to automatically include CSRF token
const originalJson = res.json.bind(res);
res.json = function(data) {
if (data && typeof data === 'object' && !data.csrfToken) {
data.csrfToken = res.locals.csrfToken;
}
return originalJson(data);
};
next();
};
/**
* Skip CSRF protection for specific routes (e.g., webhooks, public APIs)
*/
const csrfExemptRoutes = [
'/api/health',
'/api/metrics',
'/api/config',
'/api/auth/login', // Login uses credentials for authentication
'/api/auth/logout', // Logout is an authentication action
'/api/auth/session', // Session check is read-only
'/api/representatives/postal/', // Read-only operation
'/api/campaigns/public' // Public read operations
];
const conditionalCsrfProtection = (req, res, next) => {
// Skip CSRF for exempt routes
const isExempt = csrfExemptRoutes.some(route => req.path.startsWith(route));
// Skip CSRF for GET, HEAD, OPTIONS (safe methods)
const isSafeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(req.method);
if (isExempt || isSafeMethod) {
return next();
}
// Log CSRF validation attempt for debugging
console.log('=== CSRF VALIDATION ===');
console.log('Method:', req.method);
console.log('Path:', req.path);
console.log('Body Token:', req.body?._csrf ? 'YES' : 'NO');
console.log('Header Token:', req.headers['x-csrf-token'] ? 'YES' : 'NO');
console.log('CSRF Cookie:', req.cookies['_csrf'] ? 'YES' : 'NO');
console.log('Session ID:', req.session?.id || 'NO_SESSION');
console.log('=======================');
// Apply CSRF protection for state-changing operations
csrfProtection(req, res, (err) => {
if (err) {
console.log('=== CSRF ERROR ===');
console.log('Error Message:', err.message);
console.log('Error Code:', err.code);
console.log('Path:', req.path);
console.log('==================');
logger.warn('CSRF token validation failed');
csrfErrorHandler(err, req, res, next);
} else {
logger.info('CSRF validation passed for:', req.path);
next();
}
});
};
/**
* Helper to get CSRF token for client-side use
*/
const getCsrfToken = (req, res) => {
try {
// Generate a CSRF token if one doesn't exist
const token = req.csrfToken();
console.log('=== CSRF TOKEN GENERATION ===');
console.log('Token Length:', token?.length || 0);
console.log('Has Token:', !!token);
console.log('Session ID:', req.session?.id || 'NO_SESSION');
console.log('Cookie will be set:', !!req.cookies);
console.log('=============================');
res.json({
csrfToken: token
});
} catch (error) {
console.log('=== CSRF TOKEN ERROR ===');
console.log('Error:', error.message);
console.log('Stack:', error.stack);
console.log('========================');
logger.error('Failed to generate CSRF token', { error: error.message, stack: error.stack });
res.status(500).json({
error: 'Failed to generate CSRF token'
});
}
};
module.exports = {
csrfProtection,
csrfErrorHandler,
injectCsrfToken,
conditionalCsrfProtection,
getCsrfToken
};

View File

@ -0,0 +1,47 @@
const multer = require('multer');
const path = require('path');
const fs = require('fs');
// Ensure uploads directory exists
const uploadDir = path.join(__dirname, '../public/uploads/responses');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// Configure storage
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
// Generate unique filename: timestamp-random-originalname
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
const basename = path.basename(file.originalname, ext);
cb(null, `response-${uniqueSuffix}${ext}`);
}
});
// File filter - only images
const fileFilter = (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Only image files (JPEG, PNG, GIF, WebP) are allowed'));
}
};
// Configure multer
const upload = multer({
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB max file size
},
fileFilter: fileFilter
});
module.exports = upload;

View File

@ -0,0 +1,50 @@
{
"name": "alberta-influence-campaign",
"version": "1.0.0",
"description": "A locally-hosted political influence campaign tool for Alberta constituents",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "jest"
},
"keywords": [
"politics",
"alberta",
"campaign",
"represent",
"email"
],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"helmet": "^7.0.0",
"dotenv": "^16.3.1",
"express-validator": "^7.0.1",
"express-rate-limit": "^6.8.1",
"axios": "^1.5.0",
"nodemailer": "^6.9.4",
"express-session": "^1.17.3",
"bcryptjs": "^2.4.3",
"multer": "^1.4.5-lts.1",
"qrcode": "^1.5.3",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^4.7.1",
"compression": "^1.7.4",
"csurf": "^1.11.0",
"cookie-parser": "^1.4.6",
"bull": "^4.12.0",
"prom-client": "^15.1.0",
"sharp": "^0.33.0",
"ioredis": "^5.3.2"
},
"devDependencies": {
"nodemon": "^3.0.1",
"jest": "^29.6.2"
},
"engines": {
"node": ">=16.0.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,813 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="page-title">Campaign - BNKops Influence Tool</title>
<link rel="stylesheet" href="/css/styles.css">
<style>
.campaign-header {
background: linear-gradient(135deg, #3498db, #2c3e50);
color: white;
padding: 3rem 0;
text-align: center;
margin-bottom: 2rem;
position: relative;
overflow: hidden;
}
.campaign-header.has-cover {
background-size: cover;
background-position: center;
background-repeat: no-repeat;
min-height: 350px;
display: flex;
align-items: center;
justify-content: center;
}
.campaign-header.has-cover::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1;
}
.campaign-header > * {
position: relative;
z-index: 2;
}
.campaign-header h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
}
.campaign-header-content {
max-width: 800px;
margin: 0 auto;
}
.campaign-stats-header {
display: flex;
gap: 1.5rem;
justify-content: center;
margin-top: 1.5rem;
flex-wrap: wrap;
}
.stat-circle {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
width: 80px;
height: 80px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.stat-number {
font-size: 1.8rem;
font-weight: bold;
color: white;
line-height: 1;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.stat-label {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.9);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0.25rem;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
}
.share-buttons-header {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 1rem;
flex-wrap: wrap;
}
.share-btn-small {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(10px);
}
.share-btn-small:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.share-btn-small svg {
width: 18px;
height: 18px;
fill: white;
}
.share-btn-small.copied {
background: rgba(40, 167, 69, 0.8);
border-color: rgba(40, 167, 69, 1);
}
.share-more-container {
position: relative;
display: inline-block;
}
.share-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background: white;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
padding: 0.5rem;
z-index: 1000;
min-width: 200px;
}
.share-dropdown.show {
display: block;
}
.share-dropdown-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.share-dropdown-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 0.5rem;
border-radius: 6px;
background: rgba(52, 152, 219, 0.1);
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
color: #2c3e50;
}
.share-dropdown-item:hover {
background: rgba(52, 152, 219, 0.2);
transform: translateY(-2px);
}
.share-dropdown-item svg {
width: 24px;
height: 24px;
margin-bottom: 0.25rem;
fill: #2c3e50;
}
.share-dropdown-item span {
font-size: 0.7rem;
text-align: center;
line-height: 1.2;
}
.share-btn-small.more-btn {
position: relative;
}
.share-btn-small.more-btn.active {
background: rgba(255, 255, 255, 0.4);
}
.campaign-content {
max-width: 800px;
margin: 0 auto;
padding: 0 1rem;
}
.call-to-action {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
text-align: center;
}
.user-info-form {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 2rem;
}
.email-preview {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.email-preview h3 {
color: #495057;
margin-bottom: 1rem;
}
.email-subject {
font-weight: bold;
color: #2c3e50;
margin-bottom: 1rem;
}
.email-body {
line-height: 1.6;
color: #495057;
white-space: pre-wrap;
}
.email-edit-subject, .email-edit-body {
width: 100%;
border: 1px solid #ced4da;
border-radius: 4px;
padding: 0.5rem;
font-family: inherit;
margin-bottom: 1rem;
}
.email-edit-subject {
font-weight: bold;
font-size: 1rem;
}
.email-edit-body {
min-height: 150px;
resize: vertical;
line-height: 1.6;
}
.email-edit-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.preview-mode .email-edit-subject,
.preview-mode .email-edit-body,
.preview-mode .email-edit-actions {
display: none;
}
.preview-mode .email-preview-actions {
display: block !important;
}
.edit-mode .email-subject,
.edit-mode .email-body,
.edit-mode .email-preview-actions {
display: none;
}
.representatives-grid {
display: grid;
gap: 1rem;
margin-bottom: 2rem;
}
.rep-card {
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1rem;
background: white;
transition: transform 0.2s, box-shadow 0.2s;
}
.rep-card.custom-recipient {
border-left: 4px solid #9b59b6;
background: linear-gradient(135deg, #ffffff 0%, #f8f5fb 100%);
}
.rep-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.rep-info {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.rep-photo {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
background: #e9ecef;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.custom-badge {
display: inline-block;
margin-left: 0.5rem;
padding: 0.25rem 0.5rem;
background: linear-gradient(135deg, #9b59b6, #8e44ad);
color: white;
border-radius: 4px;
font-size: 0.75rem;
font-weight: normal;
vertical-align: middle;
}
.rep-details h4 {
margin: 0 0 0.25rem 0;
color: #2c3e50;
display: flex;
align-items: center;
gap: 0.5rem;
}
.rep-details p {
margin: 0;
color: #6c757d;
font-size: 0.9rem;
}
.rep-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
}
.email-method-toggle {
display: flex;
gap: 1rem;
justify-content: center;
margin-bottom: 1rem;
}
.method-option {
display: flex;
align-items: center;
gap: 0.5rem;
}
.progress-steps {
display: flex;
justify-content: space-between;
margin-bottom: 2rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
}
.step {
flex: 1;
text-align: center;
position: relative;
padding: 0.5rem;
}
.step.active {
color: #3498db;
font-weight: bold;
}
.step.completed {
color: #27ae60;
}
.step:not(:last-child)::after {
content: '→';
position: absolute;
right: -50%;
color: #6c757d;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.loading-content {
background: white;
padding: 2rem;
border-radius: 8px;
text-align: center;
}
/* Response Wall Button Styles */
.response-wall-button {
display: inline-block;
padding: 1rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 50px;
font-size: 1.1rem;
font-weight: bold;
text-align: center;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
position: relative;
overflow: hidden;
transition: transform 0.3s, box-shadow 0.3s;
animation: pulse-glow 2s ease-in-out infinite;
}
.response-wall-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
animation: none;
}
.response-wall-button::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
45deg,
transparent,
rgba(255, 255, 255, 0.1),
transparent
);
transform: rotate(45deg);
animation: shine 3s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
50% {
box-shadow: 0 4px 25px rgba(102, 126, 234, 0.8), 0 0 30px rgba(102, 126, 234, 0.5);
}
}
@keyframes shine {
0% {
left: -50%;
}
100% {
left: 150%;
}
}
.response-wall-container {
text-align: center;
margin: 2rem 0;
padding: 2rem;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 12px;
}
.response-wall-container h3 {
margin: 0 0 1rem 0;
color: #2c3e50;
font-size: 1.3rem;
}
.response-wall-container p {
margin: 0 0 1.5rem 0;
color: #666;
}
@media (max-width: 768px) {
.campaign-header h1 {
font-size: 2rem;
}
.progress-steps {
flex-direction: column;
gap: 0.5rem;
}
.step:not(:last-child)::after {
content: '↓';
position: static;
}
}
</style>
</head>
<body>
<!-- Loading Overlay -->
<div id="loading-overlay" class="loading-overlay">
<div class="loading-content">
<div class="spinner"></div>
<p id="loading-message">Loading campaign...</p>
</div>
</div>
<!-- Campaign Header -->
<div class="campaign-header">
<div class="container">
<div class="campaign-header-content">
<h1 id="campaign-title">Loading Campaign...</h1>
<p id="campaign-description"></p>
<!-- Campaign Stats in Header -->
<div id="campaign-stats-header" class="campaign-stats-header" style="display: none;">
<div id="email-stat-circle" class="stat-circle" style="display: none;">
<div class="stat-number" id="email-count-header">0</div>
<div class="stat-label">Emails</div>
</div>
<div id="call-stat-circle" class="stat-circle" style="display: none;">
<div class="stat-number" id="call-count-header">0</div>
<div class="stat-label">Calls</div>
</div>
</div>
<!-- Social Share Buttons in Header -->
<div class="share-buttons-header">
<!-- Expandable Social Menu -->
<div class="share-socials-container">
<button class="share-btn-primary" id="share-socials-toggle" title="Share on Socials">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="share-icon">
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
</svg>
<span>Socials</span>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="chevron-icon">
<path d="M7 10l5 5 5-5z"/>
</svg>
</button>
<!-- Expandable Social Options -->
<div class="share-socials-menu" id="share-socials-menu">
<button class="share-btn-small" id="share-facebook" title="Facebook">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</button>
<button class="share-btn-small" id="share-twitter" title="Twitter/X">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</button>
<button class="share-btn-small" id="share-linkedin" title="LinkedIn">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</button>
<button class="share-btn-small" id="share-whatsapp" title="WhatsApp">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/>
</svg>
</button>
<button class="share-btn-small" id="share-bluesky" title="Bluesky">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"/>
</svg>
</button>
<button class="share-btn-small" id="share-instagram" title="Instagram">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4H7.6m9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8 1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3z"/>
</svg>
</button>
<button class="share-btn-small" id="share-reddit" title="Reddit">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
</svg>
</button>
<button class="share-btn-small" id="share-threads" title="Threads">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-.542-1.947-1.499-3.488-2.846-4.576-1.488-1.2-3.457-1.806-5.854-1.826h-.01c-3.015.022-5.26.918-6.675 2.662C3.873 6.034 3.13 8.39 3.108 11.98v.014c.022 3.585.766 5.937 2.209 6.99 1.407 1.026 3.652 1.545 6.674 1.545h.01c.297 0 .597-.002.892-.009l.034 2.037c-.33.008-.665.012-1.001.012zM17.822 15.13v.002c-.184 1.376-.865 2.465-2.025 3.234-1.222.81-2.878 1.221-4.922 1.221-1.772 0-3.185-.34-4.197-1.009-.944-.625-1.488-1.527-1.617-2.68-.119-1.066.152-2.037.803-2.886.652-.85 1.595-1.464 2.802-1.823 1.102-.33 2.396-.495 3.847-.495h.343v1.615h-.343c-1.274 0-2.395.144-3.332.428-.937.284-1.653.713-2.129 1.275-.476.562-.664 1.229-.556 1.979.097.671.45 1.21 1.051 1.603.723.473 1.816.711 3.252.711 1.738 0 3.097-.35 4.042-.995.809-.552 1.348-1.349 1.603-2.373l1.98.193zM12.626 10.561v.002c-1.197 0-2.234.184-3.083.546-.938.4-1.668 1.017-2.169 1.835-.499.816-.748 1.792-.739 2.902l-2.037-.022c-.012-1.378.304-2.608.939-3.658.699-1.158 1.688-2.065 2.941-2.696 1.05-.527 2.274-.792 3.638-.792h.51v1.883h-.51z"/>
</svg>
</button>
<button class="share-btn-small" id="share-telegram" title="Telegram">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
</button>
<button class="share-btn-small" id="share-mastodon" title="Mastodon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>
</svg>
</button>
<button class="share-btn-small" id="share-sms" title="SMS">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12zM7 9h2v2H7zm4 0h2v2h-2zm4 0h2v2h-2z"/>
</svg>
</button>
<button class="share-btn-small" id="share-slack" title="Slack">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>
</button>
<button class="share-btn-small" id="share-discord" title="Discord">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
</button>
<button class="share-btn-small" id="share-print" title="Print">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/>
</svg>
</button>
<button class="share-btn-small" id="share-email" title="Email">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
</svg>
</button>
</div>
</div>
<!-- Always-visible buttons -->
<button class="share-btn-primary" id="share-copy" title="Copy Link">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
<span>Copy Link</span>
</button>
<button class="share-btn-primary" id="share-qrcode" title="Show QR Code">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zM13 3v8h8V3h-8zm6 6h-4V5h4v4zM13 13h2v2h-2zM15 15h2v2h-2zM13 17h2v2h-2zM17 13h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM19 19h2v2h-2z"/>
</svg>
<span>QR Code</span>
</button>
</div>
</div>
</div>
</div>
<div class="campaign-content">
<!-- Call to Action -->
<div id="call-to-action" class="call-to-action" style="display: none;">
<!-- Content will be loaded dynamically -->
</div>
<!-- Progress Steps -->
<div class="progress-steps">
<div class="step active" id="step-info">Enter Your Info</div>
<div class="step" id="step-postal">Find Representatives</div>
<div class="step" id="step-send">Send Messages</div>
</div>
<!-- User Information Form -->
<div id="user-info-section" class="user-info-form">
<h2>Your Information</h2>
<p>We need some basic information to find your representatives and track campaign engagement.</p>
<form id="user-info-form">
<div class="form-group">
<label for="user-postal-code">Your Postal Code *</label>
<input type="text" id="user-postal-code" name="postalCode" required
placeholder="T5K 2M5" maxlength="7" style="text-transform: uppercase;">
</div>
<div id="optional-fields" style="display: none;">
<div class="form-group">
<label for="user-name">Your Name</label>
<input type="text" id="user-name" name="userName" placeholder="Your full name">
</div>
<div class="form-group">
<label for="user-email">Your Email (Optional - If you would like a reply)</label>
<input type="email" id="user-email" name="userEmail" placeholder="your@email.com">
</div>
</div>
<button type="submit" class="btn btn-primary">Find My Representatives</button>
</form>
</div>
<!-- Email Preview -->
<div id="email-preview" class="email-preview preview-mode" style="display: none;">
<h3>📧 Email Preview</h3>
<p id="preview-description">This is the message that will be sent to your representatives:</p>
<!-- Read-only preview -->
<div class="email-subject" id="preview-subject"></div>
<div class="email-body" id="preview-body"></div>
<!-- Editable fields -->
<input type="text" class="email-edit-subject" id="edit-subject" placeholder="Email Subject">
<textarea class="email-edit-body" id="edit-body" placeholder="Email Body"></textarea>
<div class="email-edit-actions">
<button type="button" class="btn btn-secondary" id="preview-email-btn">👁️ Preview</button>
<button type="button" class="btn btn-primary" id="save-email-btn">💾 Save Changes</button>
</div>
<!-- Preview mode actions -->
<div class="email-preview-actions" style="display: none; margin-top: 1rem; text-align: center;">
<button type="button" class="btn btn-secondary" id="edit-email-btn">✏️ Edit Email</button>
</div>
</div>
<!-- Representatives Section -->
<div id="representatives-section" style="display: none;">
<h2>Your Representatives</h2>
<p>Select how you'd like to contact each representative:</p>
<!-- Email Method Selection -->
<div id="email-method-selection" class="email-method-toggle">
<div class="method-option">
<input type="radio" id="method-smtp" name="emailMethod" value="smtp" checked>
<label for="method-smtp">📧 Send via our system</label>
</div>
<div class="method-option">
<input type="radio" id="method-mailto" name="emailMethod" value="mailto">
<label for="method-mailto">📬 Open in your email app</label>
</div>
</div>
<div id="representatives-list" class="representatives-grid">
<!-- Representatives will be loaded here -->
</div>
</div>
<!-- Response Wall Button -->
<div id="response-wall-section" class="response-wall-container" style="display: none;">
<h3>💬 See What People Are Saying</h3>
<p>Check out responses to people who have taken action on this campaign</p>
<a href="#" id="response-wall-link" class="response-wall-button">
View Response Wall
</a>
</div>
<!-- Success Message -->
<div id="success-section" style="display: none; text-align: center; padding: 2rem;">
<h2 style="color: #27ae60;">🎉 Thank you for taking action!</h2>
<p>Your emails have been processed. Democracy works when people like you get involved.</p>
<button class="btn btn-secondary" data-action="reload-page">Send More Emails</button>
</div>
<!-- Error Messages -->
<div id="error-message" class="error-message" style="display: none;"></div>
</div>
<!-- QR Code Modal -->
<div id="qrcode-modal" class="qrcode-modal">
<div class="qrcode-modal-content">
<span class="qrcode-close">&times;</span>
<h2>Scan QR Code to Visit Campaign</h2>
<div class="qrcode-container">
<img id="qrcode-image" src="" alt="Campaign QR Code">
</div>
<p class="qrcode-instructions">Scan this code with your phone to visit this campaign page</p>
<button class="btn btn-secondary" id="download-qrcode-btn">Download QR Code</button>
</div>
</div>
<footer style="text-align: center; margin-top: 2rem; padding: 1rem; border-top: 1px solid #e9ecef;">
<p>&copy; 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p>
<p><small><a href="/terms.html" id="terms-link" target="_blank">Terms of Use & Privacy Notice</a></small></p>
<div style="margin-top: 1rem;">
<a href="/index.html" id="home-link" class="btn btn-secondary">Return to Main Page</a>
</div>
</footer>
<script src="/js/api-client.js"></script>
<script src="/js/campaign.js"></script>
<script>
// Update footer links with APP_URL if needed for cross-origin scenarios
fetch('/api/config')
.then(res => res.json())
.then(config => {
if (config.appUrl && !window.location.href.startsWith(config.appUrl)) {
// Only update if we're on a different domain (e.g., CDN)
document.getElementById('terms-link').href = config.appUrl + '/terms.html';
document.getElementById('home-link').href = config.appUrl + '/index.html';
}
})
.catch(err => console.log('Config not loaded, using relative paths'));
</script>
</body>
</html>

View File

@ -0,0 +1,884 @@
/* Response Wall Styles */
/* Campaign Header Styles */
.response-wall-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 3rem 0;
text-align: center;
margin-bottom: 2rem;
position: relative;
overflow: hidden;
}
.response-wall-header.has-cover {
background-size: cover;
background-position: center;
background-repeat: no-repeat;
min-height: 350px;
display: flex;
align-items: center;
justify-content: center;
}
.response-wall-header.has-cover::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1;
}
.response-wall-header > * {
position: relative;
z-index: 2;
}
.response-wall-header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
}
.response-wall-header .campaign-subtitle {
font-size: 1.6rem;
font-weight: 500;
margin: 0.5rem 0 1rem 0;
color: rgba(255, 255, 255, 0.95);
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.7);
font-style: italic;
}
.response-wall-header p {
font-size: 1.2rem;
margin-bottom: 1.5rem;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
}
.response-wall-header-content {
max-width: 800px;
margin: 0 auto;
padding: 0 1rem;
}
.header-nav-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
flex-wrap: wrap;
}
.nav-btn {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.nav-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Social Share Buttons in Header */
.share-buttons-header {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-top: 1rem;
flex-wrap: wrap;
align-items: flex-start;
}
/* Primary Share Buttons */
.share-btn-primary {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 0.75rem 1.25rem;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.share-btn-primary:hover {
background: rgba(255, 255, 255, 0.35);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.share-btn-primary svg {
width: 20px;
height: 20px;
fill: white;
}
.share-btn-primary.copied {
background: rgba(40, 167, 69, 0.9);
border-color: rgba(40, 167, 69, 1);
}
/* Expandable Social Menu Container */
.share-socials-container {
position: relative;
display: inline-block;
}
.share-socials-menu {
position: absolute;
top: calc(100% + 0.5rem);
left: 50%;
transform: translateX(-50%);
background: white;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
padding: 0.75rem;
display: none;
flex-wrap: wrap;
gap: 0.5rem;
z-index: 1000;
min-width: 280px;
max-width: 320px;
opacity: 0;
transform: translateX(-50%) translateY(-10px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.share-socials-menu.show {
display: flex;
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* Chevron icon rotation */
.share-btn-primary .chevron-icon {
width: 16px;
height: 16px;
fill: white;
transition: transform 0.3s ease;
}
.share-btn-primary.active .chevron-icon {
transform: rotate(180deg);
}
/* Share icon */
.share-btn-primary .share-icon {
width: 20px;
height: 20px;
fill: white;
}
/* Social buttons inside menu */
.share-socials-menu button {
border: none;
border-radius: 8px;
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
color: white;
}
.share-socials-menu button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
filter: brightness(1.1);
}
.share-socials-menu button svg {
width: 22px;
height: 22px;
fill: white;
}
/* Platform-specific colors */
#share-facebook {
background: #1877f2;
}
#share-twitter {
background: #000000;
}
#share-linkedin {
background: #0077b5;
}
#share-whatsapp {
background: #25d366;
}
#share-bluesky {
background: #1185fe;
}
#share-instagram {
background: linear-gradient(45deg, #f09433 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%);
}
#share-reddit {
background: #ff4500;
}
#share-threads {
background: #000000;
}
#share-telegram {
background: #0088cc;
}
#share-mastodon {
background: #6364ff;
}
#share-sms {
background: #34c759;
}
#share-slack {
background: #4a154b;
}
#share-discord {
background: #5865f2;
}
#share-print {
background: #6c757d;
}
#share-email {
background: #ea4335;
}
.share-btn-small {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(10px);
}
.share-btn-small:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.share-btn-small svg {
width: 18px;
height: 18px;
fill: white;
}
.share-btn-small.copied {
background: rgba(40, 167, 69, 0.8);
border-color: rgba(40, 167, 69, 1);
}
.stats-banner {
display: flex;
justify-content: space-around;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
border-radius: 12px;
margin-bottom: 2rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stat-item {
text-align: center;
padding: 0 1rem;
}
.stat-number {
display: block;
font-size: 3rem;
font-weight: bold;
margin-bottom: 0.5rem;
color: #ffffff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
line-height: 1;
}
.stat-label {
display: block;
font-size: 1rem;
color: #ffffff;
opacity: 1;
font-weight: 500;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.response-controls {
display: flex;
justify-content: space-between;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-group label {
font-weight: 600;
margin-bottom: 0;
}
.filter-group select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
#submit-response-btn {
margin-left: auto;
}
/* Response Card */
.response-card {
background: white;
border: 1px solid #e1e8ed;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: box-shadow 0.3s ease;
}
.response-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.response-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.response-rep-info {
flex: 1;
}
.response-rep-info h3 {
margin: 0 0 0.25rem 0;
color: #1a202c;
font-size: 1.2rem;
}
.response-rep-info .rep-meta {
color: #7f8c8d;
font-size: 0.9rem;
}
.response-rep-info .rep-meta span {
margin-right: 1rem;
}
.response-badges {
display: flex;
gap: 0.5rem;
}
.badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
}
.badge-verified {
background: #27ae60;
color: white;
}
.badge-level {
background: #3498db;
color: white;
}
.badge-type {
background: #95a5a6;
color: white;
}
.response-content {
margin-bottom: 1rem;
}
.response-text {
background: #f8f9fa;
padding: 1rem;
border-left: 4px solid #3498db;
border-radius: 4px;
margin-bottom: 1rem;
white-space: pre-wrap;
line-height: 1.6;
}
.user-comment {
padding: 0.75rem;
background: #fff3cd;
border-left: 4px solid #ffc107;
border-radius: 4px;
margin-bottom: 1rem;
}
.user-comment-label {
font-weight: 600;
color: #856404;
margin-bottom: 0.5rem;
display: block;
}
.response-screenshot {
margin-bottom: 1rem;
}
.response-screenshot img {
max-width: 100%;
border-radius: 4px;
border: 1px solid #ddd;
cursor: pointer;
transition: opacity 0.3s ease;
}
.response-screenshot img:hover {
opacity: 0.8;
}
.response-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid #e1e8ed;
}
.response-meta {
color: #7f8c8d;
font-size: 0.9rem;
}
.response-actions {
display: flex;
gap: 1rem;
}
.upvote-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 2px solid #3498db;
background: white;
color: #3498db;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
}
.upvote-btn:hover {
background: #3498db;
color: white;
transform: translateY(-2px);
}
.upvote-btn.upvoted {
background: #3498db;
color: white;
}
.upvote-btn .upvote-icon {
font-size: 1.2rem;
}
.upvote-count {
font-weight: bold;
}
/* Verify Button */
.verify-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 2px solid #27ae60;
background: white;
color: #27ae60;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
font-size: 0.9rem;
}
.verify-btn:hover {
background: #27ae60;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(39, 174, 96, 0.2);
}
.verify-btn .verify-icon {
font-size: 1.2rem;
font-weight: bold;
}
.verify-btn .verify-text {
white-space: nowrap;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem;
color: #7f8c8d;
}
.empty-state p {
font-size: 1.2rem;
margin-bottom: 1.5rem;
}
/* Modal Styles */
.modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.5);
display: none;
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 2rem;
border-radius: 8px;
max-width: 600px;
position: relative;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover,
.close:focus {
color: #000;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #1a202c;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.form-group small {
display: block;
margin-top: 0.25rem;
color: #7f8c8d;
}
/* Postal Lookup Styles */
.postal-lookup-container {
display: flex;
gap: 0.5rem;
}
.postal-lookup-container input {
flex: 1;
}
.postal-lookup-container .btn {
white-space: nowrap;
padding: 0.75rem 1rem;
}
#rep-select {
width: 100%;
padding: 0.5rem;
border: 2px solid #3498db;
border-radius: 4px;
font-size: 0.95rem;
background: white;
cursor: pointer;
}
#rep-select option {
padding: 0.5rem;
cursor: pointer;
}
#rep-select option:hover {
background: #f0f8ff;
}
#rep-select-group {
background: #f8f9fa;
padding: 1rem;
border-radius: 4px;
border: 1px solid #e1e8ed;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
.form-actions .btn {
flex: 1;
}
/* Checkbox styling */
.form-group input[type="checkbox"] {
width: auto;
margin-right: 0.5rem;
cursor: pointer;
}
.form-group label:has(input[type="checkbox"]) {
display: flex;
align-items: center;
font-weight: normal;
cursor: pointer;
}
.form-group input[type="checkbox"]:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.loading {
text-align: center;
padding: 2rem;
color: #7f8c8d;
}
.load-more-container {
text-align: center;
margin: 2rem 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.stats-banner {
flex-direction: column;
gap: 1.5rem;
}
.response-controls {
flex-direction: column;
align-items: stretch;
}
.filter-group {
flex-direction: column;
align-items: stretch;
}
#submit-response-btn {
margin-left: 0;
width: 100%;
}
.response-header {
flex-direction: column;
}
.response-badges {
margin-top: 1rem;
}
.response-footer {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.response-actions {
flex-direction: column;
width: 100%;
}
.verify-btn,
.upvote-btn {
width: 100%;
justify-content: center;
}
.modal-content {
margin: 10% 5%;
padding: 1rem;
}
}
/* QR Code Modal Styles */
.qrcode-modal {
display: none;
position: fixed;
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.7);
animation: fadeIn 0.3s ease-in-out;
}
.qrcode-modal.show {
display: flex;
justify-content: center;
align-items: center;
}
.qrcode-modal-content {
background-color: #fefefe;
margin: auto;
padding: 2rem;
border-radius: 12px;
max-width: 500px;
width: 90%;
position: relative;
animation: slideDown 0.3s ease-in-out;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.qrcode-close {
color: #aaa;
position: absolute;
right: 1.5rem;
top: 1rem;
font-size: 28px;
font-weight: bold;
cursor: pointer;
transition: color 0.2s;
}
.qrcode-close:hover,
.qrcode-close:focus {
color: #000;
}
.qrcode-modal-content h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #2c3e50;
text-align: center;
font-size: 1.5rem;
}
.qrcode-container {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 1rem;
}
.qrcode-container img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.qrcode-instructions {
text-align: center;
color: #6c757d;
margin-bottom: 1.5rem;
font-size: 0.95rem;
}
.qrcode-modal-content .btn {
width: 100%;
justify-content: center;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideDown {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Testing Interface - BNKops Influence Campaign</title>
<link rel="stylesheet" href="css/styles.css">
<style>
.test-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.test-section {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.test-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.test-controls button {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-success {
background-color: #28a745;
color: white;
}
.btn-warning {
background-color: #ffc107;
color: #212529;
}
.test-controls button:hover {
opacity: 0.9;
}
.test-controls button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.email-preview {
border: 1px solid #ccc;
border-radius: 4px;
padding: 20px;
margin-top: 20px;
background: white;
min-height: 200px;
}
.email-preview.empty {
display: flex;
align-items: center;
justify-content: center;
color: #6c757d;
font-style: italic;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.logs-section {
max-height: 400px;
overflow-y: auto;
}
.log-entry {
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
}
.log-entry.test-mode {
border-left: 4px solid #ffc107;
}
.log-entry.failed {
border-left: 4px solid #dc3545;
}
.log-entry.sent {
border-left: 4px solid #28a745;
}
.log-meta {
font-size: 12px;
color: #6c757d;
margin-top: 5px;
}
.status-indicator {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.status-sent {
background-color: #d4edda;
color: #155724;
}
.status-failed {
background-color: #f8d7da;
color: #721c24;
}
.status-test {
background-color: #fff3cd;
color: #856404;
}
.loading {
text-align: center;
padding: 20px;
color: #6c757d;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 10px;
border-radius: 4px;
margin: 10px 0;
}
.success-message {
background-color: #d4edda;
color: #155724;
padding: 10px;
border-radius: 4px;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="test-container">
<header style="text-align: center; margin-bottom: 30px;">
<h1>Email Testing Interface</h1>
<p>Test and preview emails before sending to elected officials</p>
<div id="auth-status" style="margin-top: 10px;"></div>
</header>
<!-- Quick Test Section -->
<div class="test-section">
<h2>Quick Test</h2>
<p>Send a test email to yourself to verify email configuration</p>
<div class="test-controls">
<button id="quick-test-btn" class="btn-primary">Send Quick Test Email</button>
<button id="smtp-test-btn" class="btn-secondary">Test SMTP Connection</button>
</div>
</div>
<!-- Email Composition & Preview Section -->
<div class="test-section">
<h2>Email Preview & Test</h2>
<form id="email-test-form">
<div class="form-group">
<label for="test-recipient">Test Recipient Email:</label>
<input type="email" id="test-recipient" name="recipientEmail"
placeholder="Enter email address for testing">
</div>
<div class="form-group">
<label for="test-subject">Subject:</label>
<input type="text" id="test-subject" name="subject"
placeholder="Enter email subject" required>
</div>
<div class="form-group">
<label for="test-message">Message:</label>
<textarea id="test-message" name="message"
placeholder="Enter your message to elected officials..." required></textarea>
</div>
<div class="test-controls">
<button type="button" id="preview-btn" class="btn-secondary">Preview Email</button>
<button type="button" id="send-test-btn" class="btn-primary">Send Test Email</button>
</div>
</form>
<div id="email-preview" class="email-preview empty">
Click "Preview Email" to see how your email will look
</div>
</div>
<!-- Email Logs Section -->
<div class="test-section">
<h2>Email Logs</h2>
<div class="test-controls">
<button id="refresh-logs-btn" class="btn-secondary">Refresh Logs</button>
<button id="filter-test-btn" class="btn-warning">Show Test Emails Only</button>
<button id="filter-all-btn" class="btn-success">Show All Emails</button>
</div>
<div id="email-logs" class="logs-section">
<div class="loading">Loading email logs...</div>
</div>
</div>
<!-- Test Mode Status -->
<div class="test-section">
<h2>Current Configuration</h2>
<div id="config-status">
<div class="loading">Loading configuration...</div>
</div>
</div>
</div>
<!-- Success/Error Messages -->
<div id="message-container"></div>
<!-- Scripts -->
<script src="js/auth.js"></script>
<script src="js/api-client.js"></script>
<script src="js/email-testing.js"></script>
<script>
// Initialize the email testing interface
document.addEventListener('DOMContentLoaded', function() {
// Check authentication
const authManager = new AuthManager();
authManager.checkAuthStatus().then(isAuthenticated => {
if (!isAuthenticated) {
window.location.href = '/login.html?redirect=/email-test.html';
return;
}
// Initialize email testing
const emailTest = new EmailTesting();
emailTest.init();
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,5 @@
<!-- Minimal favicon to prevent 404 errors -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#007bff"/>
<text x="16" y="20" text-anchor="middle" fill="white" font-family="Arial" font-size="16" font-weight="bold">I</text>
</svg>

After

Width:  |  Height:  |  Size: 283 B

View File

@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BNKops Influence Campaign Tool</title>
<link rel="icon" href="data:,">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<div class="container">
<main>
<!-- Postal Code Input Section -->
<section id="postal-input-section" class="unified-header-section">
<div class="section-background">
<div class="gradient-overlay"></div>
<div class="particles">
<span class="particle">🇨🇦</span>
<span class="particle">📧</span>
<span class="particle">📞</span>
<span class="particle">✉️</span>
<span class="particle">📱</span>
<span class="particle">🇨🇦</span>
<span class="particle">📧</span>
<span class="particle">📞</span>
<span class="particle">🇨🇦</span>
<span class="particle">📱</span>
</div>
</div>
<!-- Header Content -->
<div class="header-content">
<h1 class="fade-in"><a href="https://bnkops.com/" target="_blank" class="brand-link">BNKops</a> Influence Tool</h1>
<p class="fade-in-delay">Connect with your elected representatives across all levels of government</p>
</div>
<div class="map-header">
<h2>Find Your Representatives</h2>
</div>
<!-- Postal Code Input -->
<div class="postal-input-section">
<form id="postal-form">
<div class="form-group">
<label for="postal-code">Enter your postal code:</label>
<div class="input-group">
<input
type="text"
id="postal-code"
name="postal-code"
placeholder="T5K 2M5"
maxlength="7"
required
pattern="^[Tt]\d[A-Za-z]\s?\d[A-Za-z]\d$"
title="Please enter a valid Alberta postal code (starting with T)"
>
<button type="submit" class="btn btn-primary">Search</button>
<button type="button" id="refresh-btn" class="btn btn-secondary" style="display: none;">Refresh</button>
</div>
</div>
</form>
<div id="error-message" class="error-message" style="display: none;"></div>
<div id="loading" class="loading" style="display: none;">
<div class="spinner"></div>
<p>Looking up your representatives...</p>
</div>
</div>
<!-- Highlighted Campaign Section (inside blue background) -->
<div id="highlighted-campaign-section" class="highlighted-campaign-section" style="display: none;">
<div id="highlighted-campaign-container">
<!-- Highlighted campaign will be dynamically inserted here -->
</div>
</div>
</section>
<!-- Representatives Display Section -->
<section id="representatives-section" style="display: none;">
<div id="location-info" class="location-info">
<h3>Your Location</h3>
<p id="location-details"></p>
</div>
<div id="representatives-container">
<!-- Representatives will be dynamically inserted here -->
</div>
<!-- Map showing office locations -->
<div class="map-header">
<h3>Representative Office Locations</h3>
</div>
<div id="map-container" class="map-container">
<div id="main-map" style="height: 400px; width: 100%;"></div>
</div>
</section>
<!-- epose Modal -->
<div id="email-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Compose Email</h3>
<span class="close-btn" id="close-modal">&times;</span>
</div>
<div class="modal-body">
<form id="email-form">
<input type="hidden" id="recipient-email" name="recipient-email">
<input type="hidden" id="sender-postal-code" name="sender-postal-code">
<div class="form-group">
<label for="recipient-info">To:</label>
<div id="recipient-info" class="recipient-info"></div>
</div>
<div class="form-group">
<label for="sender-name">Your Name:</label>
<input type="text" id="sender-name" name="sender-name" required maxlength="100">
</div>
<div class="form-group">
<label for="sender-email">Your Email:</label>
<input type="email" id="sender-email" name="sender-email" required maxlength="200">
</div>
<div class="form-group">
<label for="email-subject">Subject:</label>
<input type="text" id="email-subject" name="email-subject" required maxlength="200">
</div>
<div class="form-group">
<label for="email-message">Message:</label>
<textarea
id="email-message"
name="email-message"
rows="10"
required
maxlength="5000"
placeholder="Write your message to your representative here..."
></textarea>
<small class="char-counter">5000 characters remaining</small>
</div>
<div class="form-actions">
<button type="button" id="cancel-email" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Preview Email</button>
</div>
</form>
</div>
</div>
</div>
<!-- Email Preview Modal -->
<div id="email-preview-modal" class="modal" style="display: none;">
<div class="modal-content preview-modal">
<div class="modal-header">
<h3>Email Preview</h3>
<span class="close-btn" id="close-preview-modal">&times;</span>
</div>
<div class="modal-body">
<div class="preview-section">
<h4>Email Details</h4>
<div class="email-details">
<div class="detail-row">
<strong>To:</strong> <span id="preview-recipient"></span>
</div>
<div class="detail-row">
<strong>From:</strong> <span id="preview-sender"></span>
</div>
<div class="detail-row">
<strong>Subject:</strong> <span id="preview-subject"></span>
</div>
</div>
</div>
<div class="preview-section">
<h4>Email Content Preview</h4>
<div id="preview-content" class="email-preview-content">
<!-- Email HTML preview will be inserted here -->
</div>
</div>
<div class="modal-actions">
<button type="button" id="edit-email" class="btn btn-secondary">
✏️ Edit Email
</button>
<button type="button" id="confirm-send" class="btn btn-primary">
📧 Send Email
</button>
<button type="button" id="cancel-preview" class="btn btn-outline">
Cancel
</button>
</div>
</div>
</div>
</div>
<!-- Success/Error Messages -->
<div id="message-display" class="message-display" style="display: none;"></div>
<!-- Campaigns Section -->
<section id="campaigns-section" style="display: none;">
<div class="campaigns-section-header">
<h2>Active Campaigns</h2>
<p>Join ongoing campaigns to make your voice heard on important issues</p>
</div>
<div id="campaigns-grid">
<!-- Campaign cards will be dynamically inserted here -->
</div>
</section>
</main>
<footer>
<p>&copy; 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p>
<p><small>This tool uses the <a href="https://represent.opennorth.ca" target="_blank">Represent API</a> by Open North to find your representatives.</small></p>
<p><small><a href="/terms.html" id="terms-link" target="_blank">Terms of Use & Privacy Notice</a></small></p>
<div class="preamble" style="text-align: center; padding: 1rem; margin: 1rem 0; background-color: #f5f5f5; border-radius: 8px;">
<p>Influence is an open-source platform and the code is available to all at <a href="https://gitea.bnkops.com/admin/changemaker.lite" target="_blank" rel="noopener noreferrer">gitea.bnkops.com/admin/changemaker.lite</a></p>
</div>
<div class="footer-actions">
<a href="/login.html" id="login-link" class="btn btn-secondary">Admin Login</a>
</div>
</footer>
</div>
<!-- Leaflet JavaScript -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<script src="js/api-client.js"></script>
<script src="js/auth.js"></script>
<script src="js/campaigns-grid.js"></script>
<script src="js/postal-lookup.js"></script>
<script src="js/representatives-display.js"></script>
<script src="js/email-composer.js"></script>
<script src="js/representatives-map.js"></script>
<script src="js/main.js"></script>
<!-- Check authentication and redirect if logged in -->
<script>
document.addEventListener('DOMContentLoaded', async () => {
// Check if user is already authenticated
if (typeof authManager !== 'undefined') {
const isAuth = await authManager.checkSession();
if (isAuth && authManager.user) {
// Redirect to appropriate dashboard
if (authManager.user.isAdmin) {
window.location.href = '/admin.html';
} else {
window.location.href = '/dashboard.html';
}
}
}
// Update navigation links with APP_URL if needed
fetch('/api/config')
.then(res => res.json())
.then(config => {
if (config.appUrl && !window.location.href.startsWith(config.appUrl)) {
document.getElementById('terms-link').href = config.appUrl + '/terms.html';
document.getElementById('login-link').href = config.appUrl + '/login.html';
}
})
.catch(err => console.log('Config not loaded, using relative paths'));
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,278 @@
// API Client for making requests to the backend
class APIClient {
constructor() {
this.baseURL = '/api';
this.csrfToken = null;
this.csrfTokenPromise = null;
}
/**
* Fetch CSRF token from the server
*/
async fetchCsrfToken() {
// If we're already fetching, return the existing promise
if (this.csrfTokenPromise) {
return this.csrfTokenPromise;
}
this.csrfTokenPromise = (async () => {
try {
console.log('Fetching CSRF token from server...');
const response = await fetch(`${this.baseURL}/csrf-token`, {
credentials: 'include' // Important: include cookies
});
const data = await response.json();
this.csrfToken = data.csrfToken;
console.log('CSRF token received:', this.csrfToken ? 'Token obtained' : 'No token');
return this.csrfToken;
} catch (error) {
console.error('Failed to fetch CSRF token:', error);
this.csrfToken = null;
throw error;
} finally {
this.csrfTokenPromise = null;
}
})();
return this.csrfTokenPromise;
}
/**
* Ensure we have a valid CSRF token
*/
async ensureCsrfToken() {
if (!this.csrfToken) {
await this.fetchCsrfToken();
}
return this.csrfToken;
}
async makeRequest(endpoint, options = {}, isRetry = false) {
// For state-changing methods, ensure we have a CSRF token
const needsCsrf = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method);
if (needsCsrf) {
await this.ensureCsrfToken();
}
const config = {
headers: {
'Content-Type': 'application/json',
...(needsCsrf && this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {}),
...options.headers
},
credentials: 'include', // Important: include cookies for CSRF
...options
};
try {
const response = await fetch(`${this.baseURL}${endpoint}`, config);
const data = await response.json();
// If response includes a new CSRF token, update it
if (data.csrfToken) {
this.csrfToken = data.csrfToken;
}
if (!response.ok) {
// If CSRF token is invalid and we haven't retried yet, fetch a new one and retry once
if (!isRetry && response.status === 403 && data.error && data.error.includes('CSRF')) {
console.log('CSRF token invalid, fetching new token and retrying...');
this.csrfToken = null;
await this.fetchCsrfToken();
// Retry the request once with new token
return this.makeRequest(endpoint, options, true);
}
// Create enhanced error with response data for better error handling
const error = new Error(data.error || data.message || `HTTP ${response.status}`);
error.status = response.status;
error.data = data;
throw error;
}
return data;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
async get(endpoint) {
return this.makeRequest(endpoint, {
method: 'GET'
});
}
async post(endpoint, data) {
return this.makeRequest(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
}
async put(endpoint, data) {
return this.makeRequest(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
}
async patch(endpoint, data) {
return this.makeRequest(endpoint, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
async delete(endpoint) {
return this.makeRequest(endpoint, {
method: 'DELETE'
});
}
async postFormData(endpoint, formData, isRetry = false) {
// Ensure we have a CSRF token for POST requests
await this.ensureCsrfToken();
console.log('Sending FormData with CSRF token:', this.csrfToken ? 'Token present' : 'No token');
// Add CSRF token to form data AND headers
if (this.csrfToken) {
formData.set('_csrf', this.csrfToken);
}
// Don't set Content-Type header - browser will set it with boundary for multipart/form-data
// But DO set CSRF token header
const config = {
method: 'POST',
headers: {
...(this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {})
},
body: formData,
credentials: 'include' // Important: include cookies for CSRF
};
try {
const response = await fetch(`${this.baseURL}${endpoint}`, config);
const data = await response.json();
// If response includes a new CSRF token, update it
if (data.csrfToken) {
this.csrfToken = data.csrfToken;
}
if (!response.ok) {
// If CSRF token is invalid and we haven't retried yet, fetch a new one and retry once
if (!isRetry && response.status === 403 && data.error && data.error.includes('CSRF')) {
console.log('CSRF token invalid, fetching new token and retrying...');
this.csrfToken = null;
await this.fetchCsrfToken();
// Update form data with new token
formData.set('_csrf', this.csrfToken);
// Retry the request once with new token
return this.postFormData(endpoint, formData, true);
}
const error = new Error(data.error || data.message || `HTTP ${response.status}`);
error.status = response.status;
error.data = data;
throw error;
}
return data;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
async putFormData(endpoint, formData, isRetry = false) {
// Ensure we have a CSRF token for PUT requests
await this.ensureCsrfToken();
// Add CSRF token to form data AND headers
if (this.csrfToken) {
formData.set('_csrf', this.csrfToken);
}
// Don't set Content-Type header - browser will set it with boundary for multipart/form-data
// But DO set CSRF token header
const config = {
method: 'PUT',
headers: {
...(this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {})
},
body: formData,
credentials: 'include' // Important: include cookies for CSRF
};
try {
const response = await fetch(`${this.baseURL}${endpoint}`, config);
const data = await response.json();
// If response includes a new CSRF token, update it
if (data.csrfToken) {
this.csrfToken = data.csrfToken;
}
if (!response.ok) {
// If CSRF token is invalid and we haven't retried yet, fetch a new one and retry once
if (!isRetry && response.status === 403 && data.error && data.error.includes('CSRF')) {
console.log('CSRF token invalid, fetching new token and retrying...');
this.csrfToken = null;
await this.fetchCsrfToken();
// Update form data with new token
formData.set('_csrf', this.csrfToken);
// Retry the request once with new token
return this.putFormData(endpoint, formData, true);
}
const error = new Error(data.error || data.message || `HTTP ${response.status}`);
error.status = response.status;
error.data = data;
throw error;
}
return data;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
// Health check
async checkHealth() {
return this.get('/health');
}
// Test Represent API connection
async testRepresent() {
return this.get('/test-represent');
}
// Get representatives by postal code
async getRepresentativesByPostalCode(postalCode) {
const cleanPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
return this.get(`/representatives/by-postal/${cleanPostalCode}`);
}
// Refresh representatives for postal code
async refreshRepresentatives(postalCode) {
const cleanPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
return this.post(`/representatives/refresh-postal/${cleanPostalCode}`);
}
// Send email to representative
async sendEmail(emailData) {
return this.post('/emails/send', emailData);
}
// Preview email before sending
async previewEmail(emailData) {
return this.post('/emails/preview', emailData);
}
}
// Create global instance
window.apiClient = new APIClient();

View File

@ -0,0 +1,237 @@
// Authentication module for handling login/logout and session management
class AuthManager {
constructor() {
this.user = null;
this.isAuthenticated = false;
}
// Initialize authentication state
async init() {
await this.checkSession();
this.setupAuthListeners();
}
// Check current session status
async checkSession() {
try {
const response = await apiClient.get('/auth/session');
if (response.authenticated) {
this.isAuthenticated = true;
this.user = response.user;
this.updateUI();
return true;
} else {
this.isAuthenticated = false;
this.user = null;
this.updateUI();
return false;
}
} catch (error) {
console.error('Session check failed:', error);
this.isAuthenticated = false;
this.user = null;
this.updateUI();
return false;
}
}
// Login with email and password
async login(email, password) {
try {
const response = await apiClient.post('/auth/login', {
email,
password
});
if (response.success) {
this.isAuthenticated = true;
this.user = response.user;
this.updateUI();
return { success: true };
} else {
return { success: false, error: response.error };
}
} catch (error) {
console.error('Login error:', error);
return { success: false, error: error.message || 'Login failed' };
}
}
// Logout current user
async logout() {
try {
await apiClient.post('/auth/logout');
this.isAuthenticated = false;
this.user = null;
this.updateUI();
// Redirect to login page
window.location.href = '/login.html';
} catch (error) {
console.error('Logout error:', error);
// Force logout on client side even if server request fails
this.isAuthenticated = false;
this.user = null;
this.updateUI();
window.location.href = '/login.html';
}
}
// Update UI based on authentication state
updateUI() {
// Update user info display
const userInfo = document.getElementById('user-info');
if (userInfo) {
if (this.isAuthenticated && this.user) {
userInfo.innerHTML = `
<span>Welcome, ${this.user.name || this.user.email}</span>
<button id="logout-btn" class="btn btn-secondary">Logout</button>
`;
// Add logout button listener
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', () => this.logout());
}
} else {
userInfo.innerHTML = '';
}
}
// Show/hide admin elements
const adminElements = document.querySelectorAll('.admin-only');
adminElements.forEach(element => {
if (this.isAuthenticated && this.user?.isAdmin) {
element.style.display = 'block';
} else {
element.style.display = 'none';
}
});
// Show/hide authenticated elements
const authElements = document.querySelectorAll('.auth-only');
authElements.forEach(element => {
if (this.isAuthenticated) {
element.style.display = 'block';
} else {
element.style.display = 'none';
}
});
}
// Redirect to appropriate dashboard
redirectToDashboard() {
if (this.isAuthenticated && this.user) {
if (this.user.isAdmin) {
window.location.href = '/admin.html';
} else {
window.location.href = '/dashboard.html';
}
} else {
window.location.href = '/login.html';
}
}
// Set up event listeners for auth-related actions
setupAuthListeners() {
// Global logout button
document.addEventListener('click', (e) => {
if (e.target.matches('[data-action="logout"]')) {
e.preventDefault();
this.logout();
}
});
// Login form submission
const loginForm = document.getElementById('login-form');
if (loginForm) {
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const result = await this.login(email, password);
if (result.success) {
// Redirect to admin panel
window.location.href = '/admin.html';
} else {
// Show error message
const errorElement = document.getElementById('error-message');
if (errorElement) {
errorElement.textContent = result.error;
errorElement.style.display = 'block';
}
}
});
}
}
// Require authentication for current page
requireAuth() {
if (!this.isAuthenticated) {
window.location.href = '/login.html';
return false;
}
return true;
}
// Require admin access for current page
requireAdmin() {
if (!this.isAuthenticated) {
window.location.href = '/login.html';
return false;
}
if (!this.user?.isAdmin) {
alert('Admin access required');
window.location.href = '/';
return false;
}
return true;
}
// Change user password
async changePassword(currentPassword, newPassword) {
try {
const response = await apiClient.post('/auth/change-password', {
currentPassword,
newPassword
});
if (response.success) {
return {
success: true,
message: response.message || 'Password changed successfully'
};
} else {
return {
success: false,
error: response.error || 'Failed to change password'
};
}
} catch (error) {
console.error('Change password failed:', error);
return {
success: false,
error: error.message || 'Failed to change password. Please try again.'
};
}
}
}
// Create global auth manager instance
const authManager = new AuthManager();
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
authManager.init();
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = AuthManager;
}

View File

@ -0,0 +1,945 @@
// Campaign Page Management Module
class CampaignPage {
constructor() {
this.campaign = null;
this.representatives = [];
this.userInfo = {};
this.currentStep = 1;
this.init();
}
init() {
// Get campaign slug from URL
const pathParts = window.location.pathname.split('/');
this.campaignSlug = pathParts[pathParts.length - 1];
// Set up form handlers
document.getElementById('user-info-form').addEventListener('submit', (e) => {
this.handleUserInfoSubmit(e);
});
// Postal code formatting
document.getElementById('user-postal-code').addEventListener('input', (e) => {
this.formatPostalCode(e);
});
// Set up social share buttons
this.setupShareButtons();
// Load campaign data
this.loadCampaign();
}
setupShareButtons() {
// Get current URL
const shareUrl = window.location.href;
// Social menu toggle
const socialsToggle = document.getElementById('share-socials-toggle');
const socialsMenu = document.getElementById('share-socials-menu');
if (socialsToggle && socialsMenu) {
socialsToggle.addEventListener('click', (e) => {
e.stopPropagation();
socialsMenu.classList.toggle('show');
socialsToggle.classList.toggle('active');
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.share-socials-container')) {
socialsMenu.classList.remove('show');
socialsToggle.classList.remove('active');
}
});
}
// Facebook share
document.getElementById('share-facebook')?.addEventListener('click', () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Twitter share
document.getElementById('share-twitter')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
const url = `https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// LinkedIn share
document.getElementById('share-linkedin')?.addEventListener('click', () => {
const url = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// WhatsApp share
document.getElementById('share-whatsapp')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
const url = `https://wa.me/?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank');
});
// Bluesky share
document.getElementById('share-bluesky')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Instagram share (note: Instagram doesn't have direct web share, opens Instagram web)
document.getElementById('share-instagram')?.addEventListener('click', () => {
alert('To share on Instagram:\n1. Copy the link (use the copy button)\n2. Open Instagram app\n3. Create a post or story\n4. Paste the link in your caption');
// Automatically copy the link
navigator.clipboard.writeText(shareUrl).catch(() => {
console.log('Failed to copy link automatically');
});
});
// Reddit share
document.getElementById('share-reddit')?.addEventListener('click', () => {
const title = this.campaign ? `${this.campaign.title}` : 'Check out this campaign';
const url = `https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(title)}`;
window.open(url, '_blank', 'width=800,height=600');
});
// Threads share
document.getElementById('share-threads')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
const url = `https://threads.net/intent/post?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Telegram share
document.getElementById('share-telegram')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
const url = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Mastodon share
document.getElementById('share-mastodon')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
// Mastodon requires instance selection - opens a composer with text
const instance = prompt('Enter your Mastodon instance (e.g., mastodon.social):');
if (instance) {
const url = `https://${instance}/share?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
}
});
// SMS share
document.getElementById('share-sms')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
const body = text + ' ' + shareUrl;
// Use Web Share API if available, otherwise fallback to SMS protocol
if (navigator.share) {
navigator.share({
title: this.campaign ? this.campaign.title : 'Campaign',
text: body
}).catch(() => {
// Fallback to SMS protocol
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
});
} else {
// SMS protocol (works on mobile)
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
}
});
// Slack share
document.getElementById('share-slack')?.addEventListener('click', () => {
const url = `https://slack.com/intl/en-ca/share?url=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Discord share
document.getElementById('share-discord')?.addEventListener('click', () => {
alert('To share on Discord:\n1. Copy the link (use the copy button)\n2. Open Discord\n3. Paste the link in any channel or DM\n\nDiscord will automatically create a preview!');
// Automatically copy the link
navigator.clipboard.writeText(shareUrl).catch(() => {
console.log('Failed to copy link automatically');
});
});
// Print/PDF share
document.getElementById('share-print')?.addEventListener('click', () => {
window.print();
});
// Email share
document.getElementById('share-email')?.addEventListener('click', () => {
const subject = this.campaign ? `Campaign: ${this.campaign.title}` : 'Check out this campaign';
const body = this.campaign ?
`I thought you might be interested in this campaign:\n\n${this.campaign.title}\n\n${shareUrl}` :
`Check out this campaign:\n\n${shareUrl}`;
window.location.href = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
});
// Copy link
document.getElementById('share-copy')?.addEventListener('click', async () => {
const copyBtn = document.getElementById('share-copy');
try {
await navigator.clipboard.writeText(shareUrl);
copyBtn.classList.add('copied');
copyBtn.title = 'Copied!';
setTimeout(() => {
copyBtn.classList.remove('copied');
copyBtn.title = 'Copy Link';
}, 2000);
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = shareUrl;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
copyBtn.classList.add('copied');
copyBtn.title = 'Copied!';
setTimeout(() => {
copyBtn.classList.remove('copied');
copyBtn.title = 'Copy Link';
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
alert('Failed to copy link. Please copy manually: ' + shareUrl);
}
document.body.removeChild(textArea);
}
});
// QR code share
document.getElementById('share-qrcode')?.addEventListener('click', () => {
this.openQRCodeModal();
});
}
openQRCodeModal() {
const modal = document.getElementById('qrcode-modal');
const qrcodeImage = document.getElementById('qrcode-image');
const closeBtn = modal.querySelector('.qrcode-close');
const downloadBtn = document.getElementById('download-qrcode-btn');
// Build QR code URL
const qrcodeUrl = `/api/campaigns/${this.campaignSlug}/qrcode?type=campaign`;
qrcodeImage.src = qrcodeUrl;
// Show modal
modal.classList.add('show');
// Close button handler
const closeModal = () => {
modal.classList.remove('show');
};
closeBtn.onclick = closeModal;
// Close when clicking outside the modal content
modal.onclick = (event) => {
if (event.target === modal) {
closeModal();
}
};
// Download button handler
downloadBtn.onclick = async () => {
try {
const response = await fetch(qrcodeUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.campaignSlug}-qrcode.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download QR code:', error);
alert('Failed to download QR code. Please try again.');
}
};
// Close on Escape key
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
}
async loadCampaign() {
this.showLoading('Loading campaign...');
try {
const data = await window.apiClient.get(`/campaigns/${this.campaignSlug}`);
if (!data.success) {
throw new Error(data.error || 'Failed to load campaign');
}
this.campaign = data.campaign;
console.log('Campaign data loaded:', this.campaign);
console.log('Cover photo value:', this.campaign.cover_photo);
this.renderCampaign();
} catch (error) {
this.showError('Failed to load campaign: ' + error.message);
} finally {
this.hideLoading();
}
}
renderCampaign() {
// Update page title and header
document.title = `${this.campaign.title} - BNKops Influence Tool`;
document.getElementById('page-title').textContent = `${this.campaign.title} - BNKops Influence Tool`;
document.getElementById('campaign-title').textContent = this.campaign.title;
document.getElementById('campaign-description').textContent = this.campaign.description;
// Add cover photo if available
const headerElement = document.querySelector('.campaign-header');
if (this.campaign.cover_photo) {
// Clean the cover_photo value - it should just be a filename
const coverPhotoFilename = String(this.campaign.cover_photo).trim();
console.log('Cover photo filename:', coverPhotoFilename);
if (coverPhotoFilename && coverPhotoFilename !== 'null' && coverPhotoFilename !== 'undefined') {
headerElement.classList.add('has-cover');
headerElement.style.backgroundImage = `url('/uploads/${coverPhotoFilename}')`;
} else {
headerElement.classList.remove('has-cover');
headerElement.style.backgroundImage = '';
}
} else {
headerElement.classList.remove('has-cover');
headerElement.style.backgroundImage = '';
}
// Show email count if enabled (show even if count is 0)
const statsHeaderSection = document.getElementById('campaign-stats-header');
let hasStats = false;
if (this.campaign.show_email_count && this.campaign.emailCount !== null && this.campaign.emailCount !== undefined) {
// Header stats
document.getElementById('email-count-header').textContent = this.campaign.emailCount;
document.getElementById('email-stat-circle').style.display = 'flex';
hasStats = true;
}
// Show call count if enabled (show even if count is 0)
if (this.campaign.show_call_count && this.campaign.callCount !== null && this.campaign.callCount !== undefined) {
// Header stats
document.getElementById('call-count-header').textContent = this.campaign.callCount;
document.getElementById('call-stat-circle').style.display = 'flex';
hasStats = true;
}
// Show stats section if any stat is enabled
if (hasStats) {
statsHeaderSection.style.display = 'flex';
}
// Show call to action
if (this.campaign.call_to_action) {
document.getElementById('call-to-action').innerHTML = `<p><strong>${this.campaign.call_to_action}</strong></p>`;
document.getElementById('call-to-action').style.display = 'block';
}
// Show response wall button if enabled
if (this.campaign.show_response_wall) {
const responseWallSection = document.getElementById('response-wall-section');
const responseWallLink = document.getElementById('response-wall-link');
if (responseWallSection && responseWallLink) {
responseWallLink.href = `/response-wall.html?campaign=${this.campaignSlug}`;
responseWallSection.style.display = 'block';
}
}
// Set up email preview
this.setupEmailPreview();
// Set up email method options
this.setupEmailMethodOptions();
// Show optional fields if user info collection is enabled
if (this.campaign.collect_user_info) {
const optionalFields = document.getElementById('optional-fields');
if (optionalFields) {
optionalFields.style.display = 'block';
console.log('Showing optional user info fields');
}
}
// Set initial step
this.setStep(1);
}
setupEmailMethodOptions() {
const emailMethodSection = document.getElementById('email-method-selection');
const allowSMTP = this.campaign.allow_smtp_email;
const allowMailto = this.campaign.allow_mailto_link;
if (!emailMethodSection) {
console.warn('Email method selection element not found');
return;
}
// Configure existing radio buttons instead of replacing HTML
const smtpRadio = document.getElementById('method-smtp');
const mailtoRadio = document.getElementById('method-mailto');
if (allowSMTP && allowMailto) {
// Both methods allowed - keep default setup
smtpRadio.disabled = false;
mailtoRadio.disabled = false;
smtpRadio.checked = true;
} else if (allowSMTP && !allowMailto) {
// Only SMTP allowed
smtpRadio.disabled = false;
mailtoRadio.disabled = true;
smtpRadio.checked = true;
} else if (!allowSMTP && allowMailto) {
// Only mailto allowed
smtpRadio.disabled = true;
mailtoRadio.disabled = false;
mailtoRadio.checked = true;
} else {
// Neither allowed - hide the section
emailMethodSection.style.display = 'none';
}
}
setupEmailPreview() {
const emailPreview = document.getElementById('email-preview');
const previewDescription = document.getElementById('preview-description');
// Store original email content
this.originalEmailSubject = this.campaign.email_subject;
this.originalEmailBody = this.campaign.email_body;
this.currentEmailSubject = this.campaign.email_subject;
this.currentEmailBody = this.campaign.email_body;
// Set up preview content
document.getElementById('preview-subject').textContent = this.currentEmailSubject;
document.getElementById('preview-body').textContent = this.currentEmailBody;
// Set up editable fields
document.getElementById('edit-subject').value = this.currentEmailSubject;
document.getElementById('edit-body').value = this.currentEmailBody;
if (this.campaign.allow_email_editing) {
// Enable editing mode
emailPreview.classList.remove('preview-mode');
emailPreview.classList.add('edit-mode');
previewDescription.textContent = 'You can edit this message before sending to your representatives:';
// Set up event listeners for editing
this.setupEmailEditingListeners();
} else {
// Read-only preview mode
emailPreview.classList.remove('edit-mode');
emailPreview.classList.add('preview-mode');
previewDescription.textContent = 'This is the message that will be sent to your representatives:';
}
emailPreview.style.display = 'block';
}
setupEmailEditingListeners() {
const editSubject = document.getElementById('edit-subject');
const editBody = document.getElementById('edit-body');
const previewBtn = document.getElementById('preview-email-btn');
const saveBtn = document.getElementById('save-email-btn');
const editBtn = document.getElementById('edit-email-btn');
// Auto-update current content as user types
editSubject.addEventListener('input', (e) => {
this.currentEmailSubject = e.target.value;
});
editBody.addEventListener('input', (e) => {
this.currentEmailBody = e.target.value;
});
// Preview button - switch to preview mode
previewBtn.addEventListener('click', () => {
this.showEmailPreview();
});
// Save button - save changes and show preview
saveBtn.addEventListener('click', () => {
this.saveEmailChanges();
});
// Edit button - switch back to edit mode
if (editBtn) {
editBtn.addEventListener('click', () => {
this.showEmailEditor();
});
}
}
showEmailPreview() {
const emailPreview = document.getElementById('email-preview');
// Update preview content
document.getElementById('preview-subject').textContent = this.currentEmailSubject;
document.getElementById('preview-body').textContent = this.currentEmailBody;
// Switch to preview mode
emailPreview.classList.remove('edit-mode');
emailPreview.classList.add('preview-mode');
}
showEmailEditor() {
const emailPreview = document.getElementById('email-preview');
// Update edit fields with current content
document.getElementById('edit-subject').value = this.currentEmailSubject;
document.getElementById('edit-body').value = this.currentEmailBody;
// Switch to edit mode
emailPreview.classList.remove('preview-mode');
emailPreview.classList.add('edit-mode');
}
toggleEmailPreview() {
const emailPreview = document.getElementById('email-preview');
const previewBtn = document.getElementById('preview-email-btn');
if (emailPreview.classList.contains('edit-mode')) {
// Switch to preview mode
document.getElementById('preview-subject').textContent = this.currentEmailSubject;
document.getElementById('preview-body').textContent = this.currentEmailBody;
emailPreview.classList.remove('edit-mode');
emailPreview.classList.add('preview-mode');
previewBtn.textContent = '✏️ Edit';
} else {
// Switch to edit mode
emailPreview.classList.remove('preview-mode');
emailPreview.classList.add('edit-mode');
previewBtn.textContent = '👁️ Preview';
}
}
saveEmailChanges() {
// Update preview content
document.getElementById('preview-subject').textContent = this.currentEmailSubject;
document.getElementById('preview-body').textContent = this.currentEmailBody;
// Switch to preview mode
this.showEmailPreview();
// Show success message
this.showMessage('Email content updated successfully!', 'success');
// Switch to preview mode
const emailPreview = document.getElementById('email-preview');
const previewBtn = document.getElementById('preview-email-btn');
emailPreview.classList.remove('edit-mode');
emailPreview.classList.add('preview-mode');
previewBtn.textContent = '✏️ Edit';
}
showMessage(message, type = 'info') {
// Use existing message display system if available
if (window.messageDisplay) {
window.messageDisplay.show(message, type);
} else {
// Fallback to alert
alert(message);
}
}
formatPostalCode(e) {
let value = e.target.value.replace(/\s/g, '').toUpperCase();
if (value.length > 3) {
value = value.substring(0, 3) + ' ' + value.substring(3, 6);
}
e.target.value = value;
}
async handleUserInfoSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
this.userInfo = {
postalCode: formData.get('postalCode').replace(/\s/g, '').toUpperCase(),
userName: formData.get('userName') || '',
userEmail: formData.get('userEmail') || ''
};
// Track user info when they click "Find My Representatives"
await this.trackUserInfo();
await this.loadRepresentatives();
}
async trackUserInfo() {
try {
const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/track-user`, {
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode
});
if (!data.success) {
console.warn('Failed to track user info:', data.error);
// Don't throw error - this is just tracking, shouldn't block the user
}
} catch (error) {
console.warn('Failed to track user info:', error.message);
// Don't throw error - this is just tracking, shouldn't block the user
}
}
async loadRepresentatives() {
this.showLoading('Finding your representatives...');
try {
const data = await window.apiClient.get(`/campaigns/${this.campaignSlug}/representatives/${this.userInfo.postalCode}`);
if (!data.success) {
throw new Error(data.error || 'Failed to load representatives');
}
this.representatives = data.representatives;
this.renderRepresentatives();
this.setStep(2);
// Scroll to representatives section
document.getElementById('representatives-section').scrollIntoView({
behavior: 'smooth'
});
} catch (error) {
this.showError('Failed to load representatives: ' + error.message);
} finally {
this.hideLoading();
}
}
renderRepresentatives() {
const list = document.getElementById('representatives-list');
if (this.representatives.length === 0) {
list.innerHTML = '<p>No representatives found for your area. Please check your postal code.</p>';
return;
}
list.innerHTML = this.representatives.map(rep => `
<div class="rep-card ${rep.is_custom_recipient ? 'custom-recipient' : ''}">
<div class="rep-info">
${rep.photo_url ?
`<img src="${rep.photo_url}" alt="${rep.name}" class="rep-photo">` :
`<div class="rep-photo">${rep.is_custom_recipient ? '👤' : ''}</div>`
}
<div class="rep-details">
<h4>
${rep.name}
${rep.is_custom_recipient ? '<span class="custom-badge" title="Custom Recipient">✉️</span>' : ''}
</h4>
<p>${rep.elected_office || 'Representative'}</p>
<p>${rep.party_name || ''}</p>
${rep.email ? `<p>📧 ${rep.email}</p>` : ''}
${this.getPhoneNumber(rep) ? `<p>📞 ${this.getPhoneNumber(rep)}</p>` : ''}
</div>
</div>
<div class="rep-actions">
${rep.email ? `
<button class="btn btn-primary" data-action="send-email"
data-email="${rep.email}"
data-name="${rep.name}"
data-title="${rep.elected_office || ''}"
data-level="${this.getGovernmentLevel(rep)}"
data-is-custom="${rep.is_custom_recipient || false}">
Send Email
</button>
` : ''}
${this.getPhoneNumber(rep) && !rep.is_custom_recipient ? `
<button class="btn btn-success" data-action="call-representative"
data-phone="${this.getPhoneNumber(rep)}"
data-name="${rep.name}"
data-title="${rep.elected_office || ''}"
data-office-type="${this.getPhoneOfficeType(rep)}">
📞 Call
</button>
` : ''}
${!rep.email && !this.getPhoneNumber(rep) ? '<p style="text-align: center; color: #6c757d;">No contact information available</p>' : ''}
</div>
</div>
`).join('');
// Attach event listeners to send email buttons
this.attachEmailButtonListeners();
document.getElementById('representatives-section').style.display = 'block';
}
attachEmailButtonListeners() {
// Send email buttons
document.querySelectorAll('[data-action="send-email"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const email = e.target.dataset.email;
const name = e.target.dataset.name;
const title = e.target.dataset.title;
const level = e.target.dataset.level;
const isCustom = e.target.dataset.isCustom === 'true';
this.sendEmail(email, name, title, level, isCustom);
});
});
// Call buttons
document.querySelectorAll('[data-action="call-representative"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const phone = e.target.dataset.phone;
const name = e.target.dataset.name;
const title = e.target.dataset.title;
const officeType = e.target.dataset.officeType;
this.callRepresentative(phone, name, title, officeType);
});
});
// Reload page button
const reloadBtn = document.querySelector('[data-action="reload-page"]');
if (reloadBtn) {
reloadBtn.addEventListener('click', () => {
location.reload();
});
}
}
getGovernmentLevel(rep) {
const office = (rep.elected_office || '').toLowerCase();
if (office.includes('mp') || office.includes('member of parliament')) return 'Federal';
if (office.includes('mla') || office.includes('legislative assembly')) return 'Provincial';
if (office.includes('mayor') || office.includes('councillor')) return 'Municipal';
if (office.includes('school')) return 'School Board';
return 'Other';
}
getPhoneNumber(rep) {
if (!rep.offices || !Array.isArray(rep.offices)) {
return null;
}
// Find the first office with a phone number
const officeWithPhone = rep.offices.find(office => office.tel);
return officeWithPhone ? officeWithPhone.tel : null;
}
getPhoneOfficeType(rep) {
if (!rep.offices || !Array.isArray(rep.offices)) {
return 'office';
}
const officeWithPhone = rep.offices.find(office => office.tel);
return officeWithPhone ? (officeWithPhone.type || 'office') : 'office';
}
callRepresentative(phone, name, title, officeType) {
// Clean the phone number for tel: link (remove spaces, dashes, parentheses)
const cleanPhone = phone.replace(/[\s\-\(\)]/g, '');
// Create tel: link
const telLink = `tel:${cleanPhone}`;
// Show confirmation dialog with formatted information
const officeInfo = officeType ? ` (${officeType} office)` : '';
const message = `Call ${name}${title ? ` - ${title}` : ''}${officeInfo}?\n\nPhone: ${phone}`;
if (confirm(message)) {
// Attempt to initiate the call
window.location.href = telLink;
// Track the call attempt
this.trackCall(phone, name, title, officeType);
}
}
async trackCall(phone, name, title, officeType) {
try {
const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/track-call`, {
representativeName: name,
representativeTitle: title || '',
phoneNumber: phone,
officeType: officeType || '',
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode
});
if (data.success) {
this.showCallSuccess('Call tracked successfully!');
}
} catch (error) {
console.error('Failed to track call:', error);
}
}
async sendEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, isCustom = false) {
const emailMethod = document.querySelector('input[name="emailMethod"]:checked').value;
// Use "Custom Recipient" as level if this is a custom recipient and no level provided
const finalLevel = isCustom && !recipientLevel ? 'Custom Recipient' : recipientLevel;
if (emailMethod === 'mailto') {
this.openMailtoLink(recipientEmail, recipientName, recipientTitle, finalLevel);
} else {
await this.sendSMTPEmail(recipientEmail, recipientName, recipientTitle, finalLevel);
}
}
openMailtoLink(recipientEmail, recipientName, recipientTitle, recipientLevel) {
const subject = encodeURIComponent(this.currentEmailSubject || this.campaign.email_subject);
const body = encodeURIComponent(this.currentEmailBody || this.campaign.email_body);
const mailtoUrl = `mailto:${recipientEmail}?subject=${subject}&body=${body}`;
// Track the mailto click
this.trackEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, 'mailto');
window.open(mailtoUrl);
}
async sendSMTPEmail(recipientEmail, recipientName, recipientTitle, recipientLevel) {
this.showLoading('Sending email...');
try {
const emailData = {
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode,
recipientEmail,
recipientName,
recipientTitle,
recipientLevel,
emailMethod: 'smtp'
};
// Include custom email content if email editing is enabled
if (this.campaign.allow_email_editing) {
emailData.customEmailSubject = this.currentEmailSubject || this.campaign.email_subject;
emailData.customEmailBody = this.currentEmailBody || this.campaign.email_body;
}
const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/send-email`, emailData);
if (data.success) {
this.showSuccess('Email sent successfully!');
} else {
throw new Error(data.error || 'Failed to send email');
}
} catch (error) {
// Handle rate limit errors specifically
if (error.message && error.message.includes('You can only send one email per representative every 5 minutes')) {
this.showError('Rate limit reached: You can only send one email per representative every 5 minutes. Please wait before sending another email to this representative. You can still email other representatives.');
} else if (error.message && error.message.includes('Too many emails')) {
this.showError('Too many emails sent. Please wait before sending more emails.');
} else {
this.showError('Failed to send email: ' + error.message);
}
} finally {
this.hideLoading();
}
}
async trackEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, emailMethod) {
try {
await window.apiClient.post(`/campaigns/${this.campaignSlug}/send-email`, {
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode,
recipientEmail,
recipientName,
recipientTitle,
recipientLevel,
emailMethod
});
} catch (error) {
console.error('Failed to track email:', error);
}
}
setStep(step) {
// Reset all steps
document.querySelectorAll('.step').forEach(s => {
s.classList.remove('active', 'completed');
});
// Mark completed steps
for (let i = 1; i < step; i++) {
document.getElementById(`step-${this.getStepName(i)}`).classList.add('completed');
}
// Mark current step
document.getElementById(`step-${this.getStepName(step)}`).classList.add('active');
this.currentStep = step;
}
getStepName(step) {
const steps = ['', 'info', 'postal', 'send'];
return steps[step] || 'info';
}
showLoading(message) {
document.getElementById('loading-message').textContent = message;
document.getElementById('loading-overlay').style.display = 'flex';
}
hideLoading() {
document.getElementById('loading-overlay').style.display = 'none';
}
showError(message) {
const errorDiv = document.getElementById('error-message');
errorDiv.textContent = message;
errorDiv.style.display = 'block';
setTimeout(() => {
errorDiv.style.display = 'none';
}, 5000);
}
showSuccess(message) {
// Update email count if enabled
if (this.campaign.show_email_count) {
const countHeaderElement = document.getElementById('email-count-header');
const currentCount = parseInt(countHeaderElement?.textContent) || 0;
const newCount = currentCount + 1;
if (countHeaderElement) {
countHeaderElement.textContent = newCount;
}
}
// You could show a toast or update UI to indicate success
alert(message); // Simple for now, could be improved with better UI
}
showCallSuccess(message) {
// Update call count if enabled
if (this.campaign.show_call_count) {
const countHeaderElement = document.getElementById('call-count-header');
const currentCount = parseInt(countHeaderElement?.textContent) || 0;
const newCount = currentCount + 1;
if (countHeaderElement) {
countHeaderElement.textContent = newCount;
}
}
// Show success message
alert(message);
}
}
// Initialize the campaign page when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.campaignPage = new CampaignPage();
});

View File

@ -0,0 +1,351 @@
// Campaigns Grid Module
// Displays public campaigns in a responsive grid on the homepage
class CampaignsGrid {
constructor() {
this.campaigns = [];
this.container = null;
this.loading = false;
this.error = null;
}
async init() {
this.container = document.getElementById('campaigns-grid');
if (!this.container) {
console.error('Campaigns grid container not found');
return;
}
await this.loadCampaigns();
}
async loadCampaigns() {
if (this.loading) return;
this.loading = true;
this.showLoading();
try {
const response = await fetch('/api/public/campaigns');
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load campaigns');
}
this.campaigns = data.campaigns || [];
this.renderCampaigns();
// Show or hide the entire campaigns section based on availability
const campaignsSection = document.getElementById('campaigns-section');
if (this.campaigns.length > 0) {
campaignsSection.style.display = 'block';
} else {
campaignsSection.style.display = 'none';
}
} catch (error) {
console.error('Error loading campaigns:', error);
this.showError('Unable to load campaigns. Please try again later.');
} finally {
this.loading = false;
}
}
renderCampaigns() {
if (!this.container) return;
if (this.campaigns.length === 0) {
this.container.innerHTML = `
<div class="campaigns-empty">
<p>No active campaigns at the moment. Check back soon!</p>
</div>
`;
return;
}
// Sort campaigns by created_at date (newest first)
const sortedCampaigns = [...this.campaigns].sort((a, b) => {
const dateA = new Date(a.created_at || 0);
const dateB = new Date(b.created_at || 0);
return dateB - dateA;
});
const campaignsHTML = sortedCampaigns.map(campaign => this.renderCampaignCard(campaign)).join('');
this.container.innerHTML = campaignsHTML;
// Trigger animations by forcing a reflow
this.triggerAnimations();
// Add click event listeners to campaign cards (no inline handlers)
this.attachCardClickHandlers();
}
triggerAnimations() {
// Force reflow to restart CSS animations when campaigns are re-rendered
const cards = this.container.querySelectorAll('.campaign-card');
cards.forEach((card, index) => {
// Remove and re-add animation to restart it
card.style.animation = 'none';
// Force reflow
void card.offsetHeight;
// Re-apply animation with staggered delay
card.style.animation = `campaignFadeInUp 0.6s ease ${0.1 * (index + 1)}s forwards`;
});
}
attachCardClickHandlers() {
const campaignCards = this.container.querySelectorAll('.campaign-card');
campaignCards.forEach(card => {
const slug = card.getAttribute('data-slug');
if (slug) {
// Handle card click (but not share buttons)
card.addEventListener('click', (e) => {
// Don't navigate if clicking on share buttons
if (e.target.closest('.share-btn') || e.target.closest('.campaign-card-social-share')) {
return;
}
window.location.href = `/campaign/${slug}`;
});
// Add keyboard accessibility
card.setAttribute('tabindex', '0');
card.setAttribute('role', 'link');
card.setAttribute('aria-label', `View campaign: ${card.querySelector('.campaign-card-title')?.textContent || 'campaign'}`);
card.addEventListener('keypress', (e) => {
if (e.target.closest('.share-btn')) {
return; // Let share buttons handle their own keyboard events
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
window.location.href = `/campaign/${slug}`;
}
});
// Attach share button handlers
const shareButtons = card.querySelectorAll('.share-btn');
shareButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
const platform = btn.getAttribute('data-platform');
const title = card.querySelector('.campaign-card-title')?.textContent || 'Campaign';
const description = card.querySelector('.campaign-card-description')?.textContent || '';
this.handleShare(platform, slug, title, description);
});
});
}
});
}
renderCampaignCard(campaign) {
const coverPhotoStyle = campaign.cover_photo
? `background-image: url('/uploads/${campaign.cover_photo}'); background-size: cover; background-position: center;`
: 'background: linear-gradient(135deg, #3498db, #2c3e50);';
const emailCountBadge = campaign.show_email_count && campaign.emailCount !== null
? `<div class="campaign-card-stat">
<span class="stat-icon">📧</span>
<span class="stat-value">${campaign.emailCount}</span>
<span class="stat-label">emails sent</span>
</div>`
: '';
const callCountBadge = campaign.show_call_count && campaign.callCount !== null
? `<div class="campaign-card-stat">
<span class="stat-icon">📞</span>
<span class="stat-value">${campaign.callCount}</span>
<span class="stat-label">calls made</span>
</div>`
: '';
const verifiedResponseBadge = campaign.verifiedResponseCount > 0
? `<div class="campaign-card-stat verified-response">
<span class="stat-icon"></span>
<span class="stat-value">${campaign.verifiedResponseCount}</span>
<span class="stat-label">verified ${campaign.verifiedResponseCount === 1 ? 'response' : 'responses'}</span>
</div>`
: '';
const targetLevels = Array.isArray(campaign.target_government_levels) && campaign.target_government_levels.length > 0
? campaign.target_government_levels.map(level => `<span class="level-badge">${level}</span>`).join('')
: '';
// Truncate description to reasonable length
const description = campaign.description || '';
const truncatedDescription = description.length > 150
? description.substring(0, 150) + '...'
: description;
return `
<div class="campaign-card" data-slug="${campaign.slug}">
<div class="campaign-card-image" style="${coverPhotoStyle}">
<div class="campaign-card-overlay"></div>
<h3 class="campaign-card-title">${this.escapeHtml(campaign.title)}</h3>
</div>
<div class="campaign-card-content">
<p class="campaign-card-description">${this.escapeHtml(truncatedDescription)}</p>
${targetLevels ? `<div class="campaign-card-levels">${targetLevels}</div>` : ''}
<div class="campaign-card-stats">
${emailCountBadge}
${callCountBadge}
</div>
<div class="campaign-card-social-share">
<span class="share-label">Share:</span>
<button class="share-btn share-twitter" data-platform="twitter" title="Share on Twitter/X" aria-label="Share on Twitter/X">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</button>
<button class="share-btn share-facebook" data-platform="facebook" title="Share on Facebook" aria-label="Share on Facebook">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</button>
<button class="share-btn share-linkedin" data-platform="linkedin" title="Share on LinkedIn" aria-label="Share on LinkedIn">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</button>
<button class="share-btn share-reddit" data-platform="reddit" title="Share on Reddit" aria-label="Share on Reddit">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
</svg>
</button>
<button class="share-btn share-email" data-platform="email" title="Share via Email" aria-label="Share via Email">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
</svg>
</button>
<button class="share-btn share-copy" data-platform="copy" title="Copy Link" aria-label="Copy Link">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
</button>
</div>
${verifiedResponseBadge}
<div class="campaign-card-action">
<span class="btn-link">Learn More & Participate </span>
</div>
</div>
</div>
`;
}
showLoading() {
if (!this.container) return;
this.container.innerHTML = `
<div class="campaigns-loading">
<div class="spinner"></div>
<p>Loading campaigns...</p>
</div>
`;
}
showError(message) {
if (!this.container) return;
this.container.innerHTML = `
<div class="campaigns-error">
<p> ${this.escapeHtml(message)}</p>
</div>
`;
}
escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
handleShare(platform, slug, title, description) {
const campaignUrl = `${window.location.origin}/campaign/${slug}`;
const shareText = `${title} - ${description}`;
let shareUrl = '';
switch(platform) {
case 'twitter':
shareUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(campaignUrl)}`;
window.open(shareUrl, '_blank', 'width=550,height=420');
break;
case 'facebook':
shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(campaignUrl)}`;
window.open(shareUrl, '_blank', 'width=550,height=420');
break;
case 'linkedin':
shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(campaignUrl)}`;
window.open(shareUrl, '_blank', 'width=550,height=420');
break;
case 'reddit':
shareUrl = `https://www.reddit.com/submit?url=${encodeURIComponent(campaignUrl)}&title=${encodeURIComponent(title)}`;
window.open(shareUrl, '_blank', 'width=550,height=420');
break;
case 'email':
const emailSubject = `Check out this campaign: ${title}`;
const emailBody = `${shareText}\n\nLearn more and participate: ${campaignUrl}`;
window.location.href = `mailto:?subject=${encodeURIComponent(emailSubject)}&body=${encodeURIComponent(emailBody)}`;
break;
case 'copy':
this.copyToClipboard(campaignUrl);
break;
}
}
async copyToClipboard(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
this.showShareFeedback('Link copied to clipboard!');
} catch (err) {
console.error('Failed to copy:', err);
this.showShareFeedback('Failed to copy link', true);
}
}
showShareFeedback(message, isError = false) {
// Create or get feedback element
let feedback = document.getElementById('share-feedback');
if (!feedback) {
feedback = document.createElement('div');
feedback.id = 'share-feedback';
feedback.className = 'share-feedback';
document.body.appendChild(feedback);
}
feedback.textContent = message;
feedback.className = `share-feedback ${isError ? 'error' : 'success'} show`;
// Auto-hide after 3 seconds
setTimeout(() => {
feedback.classList.remove('show');
}, 3000);
}
}
// Export for use in main.js
if (typeof window !== 'undefined') {
window.CampaignsGrid = CampaignsGrid;
}

View File

@ -0,0 +1,525 @@
/**
* Custom Recipients Management Module
* Handles CRUD operations for custom email recipients in campaigns
*/
console.log('Custom Recipients module loading...');
const CustomRecipients = (() => {
console.log('Custom Recipients module initialized');
let currentCampaignSlug = null;
let recipients = [];
/**
* Initialize the module with a campaign slug
*/
function init(campaignSlug) {
console.log('CustomRecipients.init() called with slug:', campaignSlug);
currentCampaignSlug = campaignSlug;
// Setup event listeners every time init is called
// Use setTimeout to ensure DOM is ready
setTimeout(() => {
setupEventListeners();
console.log('CustomRecipients event listeners set up');
}, 100);
}
/**
* Setup event listeners for custom recipients UI
*/
function setupEventListeners() {
console.log('Setting up CustomRecipients event listeners');
// Add recipient form submit
const addForm = document.getElementById('add-recipient-form');
console.log('Add recipient form found:', addForm);
if (addForm) {
// Remove any existing listener first
addForm.removeEventListener('submit', handleAddRecipient);
addForm.addEventListener('submit', handleAddRecipient);
console.log('Form submit listener attached');
}
// Bulk import button
const bulkImportBtn = document.getElementById('bulk-import-recipients-btn');
if (bulkImportBtn) {
bulkImportBtn.removeEventListener('click', openBulkImportModal);
bulkImportBtn.addEventListener('click', openBulkImportModal);
}
// Clear all recipients button
const clearAllBtn = document.getElementById('clear-all-recipients-btn');
if (clearAllBtn) {
clearAllBtn.removeEventListener('click', handleClearAll);
clearAllBtn.addEventListener('click', handleClearAll);
}
// Bulk import modal buttons
const importBtn = document.getElementById('import-recipients-btn');
if (importBtn) {
importBtn.removeEventListener('click', handleBulkImport);
importBtn.addEventListener('click', handleBulkImport);
}
const cancelBtn = document.querySelector('#bulk-import-modal .cancel');
if (cancelBtn) {
cancelBtn.removeEventListener('click', closeBulkImportModal);
cancelBtn.addEventListener('click', closeBulkImportModal);
}
// Close modal on backdrop click
const modal = document.getElementById('bulk-import-modal');
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeBulkImportModal();
}
});
}
}
/**
* Load recipients for the current campaign
*/
async function loadRecipients(campaignSlug) {
console.log('loadRecipients() called with campaignSlug:', campaignSlug);
console.log('currentCampaignSlug:', currentCampaignSlug);
// Use provided slug or fall back to currentCampaignSlug
const slug = campaignSlug || currentCampaignSlug;
if (!slug) {
console.error('No campaign slug available to load recipients');
showMessage('No campaign selected', 'error');
return [];
}
try {
console.log('Fetching recipients from:', `/campaigns/${slug}/custom-recipients`);
const data = await window.apiClient.get(`/campaigns/${slug}/custom-recipients`);
console.log('Recipients data received:', data);
recipients = data.recipients || [];
console.log('Loaded recipients count:', recipients.length);
displayRecipients();
return recipients;
} catch (error) {
console.error('Error loading recipients:', error);
showMessage('Failed to load recipients: ' + error.message, 'error');
return [];
}
}
/**
* Display recipients list
*/
function displayRecipients() {
console.log('displayRecipients() called, recipients count:', recipients.length);
const container = document.getElementById('recipients-list');
console.log('Recipients container found:', container);
if (!container) {
console.error('Recipients list container not found!');
return;
}
if (recipients.length === 0) {
container.innerHTML = '<div class="empty-state">No custom recipients added yet. Use the form above to add recipients.</div>';
console.log('Displayed empty state');
return;
}
console.log('Rendering', recipients.length, 'recipients');
container.innerHTML = recipients.map(recipient => `
<div class="recipient-card" data-id="${recipient.id}">
<div class="recipient-info">
<div class="recipient-name">${escapeHtml(recipient.recipient_name)}</div>
<div class="recipient-email">${escapeHtml(recipient.recipient_email)}</div>
${recipient.recipient_title ? `<div class="recipient-meta">${escapeHtml(recipient.recipient_title)}</div>` : ''}
${recipient.recipient_organization ? `<div class="recipient-meta">${escapeHtml(recipient.recipient_organization)}</div>` : ''}
${recipient.notes ? `<div class="recipient-meta"><em>${escapeHtml(recipient.notes)}</em></div>` : ''}
</div>
<div class="recipient-actions">
<button class="btn-icon edit-recipient" data-id="${recipient.id}" title="Edit recipient">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/>
</svg>
</button>
<button class="btn-icon delete-recipient" data-id="${recipient.id}" title="Delete recipient">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
</div>
</div>
`).join('');
// Add event listeners to edit and delete buttons
container.querySelectorAll('.edit-recipient').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.currentTarget.dataset.id;
handleEditRecipient(id);
});
});
container.querySelectorAll('.delete-recipient').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.currentTarget.dataset.id;
handleDeleteRecipient(id);
});
});
}
/**
* Handle add recipient form submission
*/
async function handleAddRecipient(e) {
console.log('handleAddRecipient called, event:', e);
e.preventDefault();
console.log('Form submission prevented, currentCampaignSlug:', currentCampaignSlug);
const formData = {
recipient_name: document.getElementById('recipient-name').value.trim(),
recipient_email: document.getElementById('recipient-email').value.trim(),
recipient_title: document.getElementById('recipient-title').value.trim(),
recipient_organization: document.getElementById('recipient-organization').value.trim(),
notes: document.getElementById('recipient-notes').value.trim()
};
console.log('Form data collected:', formData);
// Validate email
if (!validateEmail(formData.recipient_email)) {
console.error('Email validation failed');
showMessage('Please enter a valid email address', 'error');
return;
}
console.log('Email validation passed');
try {
const url = `/campaigns/${currentCampaignSlug}/custom-recipients`;
console.log('Making POST request to:', url);
const data = await window.apiClient.post(url, formData);
console.log('Response data:', data);
showMessage('Recipient added successfully', 'success');
e.target.reset();
await loadRecipients(currentCampaignSlug);
} catch (error) {
console.error('Error adding recipient:', error);
showMessage('Failed to add recipient: ' + error.message, 'error');
}
}
/**
* Handle edit recipient
*/
async function handleEditRecipient(recipientId) {
const recipient = recipients.find(r => r.id == recipientId);
if (!recipient) return;
// Populate form with recipient data
document.getElementById('recipient-name').value = recipient.recipient_name || '';
document.getElementById('recipient-email').value = recipient.recipient_email || '';
document.getElementById('recipient-title').value = recipient.recipient_title || '';
document.getElementById('recipient-organization').value = recipient.recipient_organization || '';
document.getElementById('recipient-notes').value = recipient.notes || '';
// Change form behavior to update instead of create
const form = document.getElementById('add-recipient-form');
const submitBtn = form.querySelector('button[type="submit"]');
// Store the recipient ID for update
form.dataset.editingId = recipientId;
submitBtn.textContent = 'Update Recipient';
// Add cancel button if it doesn't exist
let cancelBtn = form.querySelector('.cancel-edit-btn');
if (!cancelBtn) {
cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'btn secondary cancel-edit-btn';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', cancelEdit);
submitBtn.parentNode.insertBefore(cancelBtn, submitBtn.nextSibling);
}
// Update form submit handler
form.removeEventListener('submit', handleAddRecipient);
form.addEventListener('submit', handleUpdateRecipient);
// Scroll to form
form.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
/**
* Handle update recipient
*/
async function handleUpdateRecipient(e) {
e.preventDefault();
const form = e.target;
const recipientId = form.dataset.editingId;
const formData = {
recipient_name: document.getElementById('recipient-name').value.trim(),
recipient_email: document.getElementById('recipient-email').value.trim(),
recipient_title: document.getElementById('recipient-title').value.trim(),
recipient_organization: document.getElementById('recipient-organization').value.trim(),
notes: document.getElementById('recipient-notes').value.trim()
};
// Validate email
if (!validateEmail(formData.recipient_email)) {
showMessage('Please enter a valid email address', 'error');
return;
}
try {
const data = await window.apiClient.put(`/campaigns/${currentCampaignSlug}/custom-recipients/${recipientId}`, formData);
showMessage('Recipient updated successfully', 'success');
cancelEdit();
await loadRecipients(currentCampaignSlug);
} catch (error) {
console.error('Error updating recipient:', error);
showMessage('Failed to update recipient: ' + error.message, 'error');
}
}
/**
* Cancel edit mode
*/
function cancelEdit() {
const form = document.getElementById('add-recipient-form');
const submitBtn = form.querySelector('button[type="submit"]');
const cancelBtn = form.querySelector('.cancel-edit-btn');
// Reset form
form.reset();
delete form.dataset.editingId;
submitBtn.textContent = 'Add Recipient';
// Remove cancel button
if (cancelBtn) {
cancelBtn.remove();
}
// Restore original submit handler
form.removeEventListener('submit', handleUpdateRecipient);
form.addEventListener('submit', handleAddRecipient);
}
/**
* Handle delete recipient
*/
async function handleDeleteRecipient(recipientId) {
if (!confirm('Are you sure you want to delete this recipient?')) {
return;
}
try {
const data = await window.apiClient.delete(`/campaigns/${currentCampaignSlug}/custom-recipients/${recipientId}`);
showMessage('Recipient deleted successfully', 'success');
await loadRecipients(currentCampaignSlug);
} catch (error) {
console.error('Error deleting recipient:', error);
showMessage('Failed to delete recipient: ' + error.message, 'error');
}
}
/**
* Handle clear all recipients
*/
async function handleClearAll() {
if (!confirm('Are you sure you want to delete ALL custom recipients for this campaign? This cannot be undone.')) {
return;
}
try {
const data = await window.apiClient.delete(`/campaigns/${currentCampaignSlug}/custom-recipients`);
showMessage(`Successfully deleted ${data.deletedCount} recipient(s)`, 'success');
await loadRecipients(currentCampaignSlug);
} catch (error) {
console.error('Error deleting all recipients:', error);
showMessage('Failed to delete recipients: ' + error.message, 'error');
}
}
/**
* Open bulk import modal
*/
function openBulkImportModal() {
const modal = document.getElementById('bulk-import-modal');
if (modal) {
modal.style.display = 'block';
// Clear previous results
document.getElementById('import-results').innerHTML = '';
document.getElementById('csv-file-input').value = '';
document.getElementById('csv-paste-input').value = '';
}
}
/**
* Close bulk import modal
*/
function closeBulkImportModal() {
const modal = document.getElementById('bulk-import-modal');
if (modal) {
modal.style.display = 'none';
}
}
/**
* Handle bulk import
*/
async function handleBulkImport() {
const fileInput = document.getElementById('csv-file-input');
const pasteInput = document.getElementById('csv-paste-input');
const resultsDiv = document.getElementById('import-results');
let csvText = '';
// Check file input first
if (fileInput.files.length > 0) {
const file = fileInput.files[0];
csvText = await readFileAsText(file);
} else if (pasteInput.value.trim()) {
csvText = pasteInput.value.trim();
} else {
resultsDiv.innerHTML = '<div class="error">Please select a CSV file or paste CSV data</div>';
return;
}
// Parse CSV
const parsedRecipients = parseCsv(csvText);
if (parsedRecipients.length === 0) {
resultsDiv.innerHTML = '<div class="error">No valid recipients found in CSV</div>';
return;
}
// Show loading
resultsDiv.innerHTML = '<div class="loading">Importing recipients...</div>';
try {
const data = await window.apiClient.post(`/campaigns/${currentCampaignSlug}/custom-recipients/bulk`, { recipients: parsedRecipients });
if (data.success) {
const { results } = data;
let html = `<div class="success">Successfully imported ${results.success.length} of ${results.total} recipients</div>`;
if (results.failed.length > 0) {
html += '<div class="failed-imports"><strong>Failed imports:</strong><ul>';
results.failed.forEach(failure => {
html += `<li>${escapeHtml(failure.recipient.recipient_name || 'Unknown')} (${escapeHtml(failure.recipient.recipient_email || 'No email')}): ${escapeHtml(failure.error)}</li>`;
});
html += '</ul></div>';
}
resultsDiv.innerHTML = html;
await loadRecipients(currentCampaignSlug);
// Close modal after 3 seconds if all successful
if (results.failed.length === 0) {
setTimeout(closeBulkImportModal, 3000);
}
} else {
throw new Error(data.error || 'Failed to import recipients');
}
} catch (error) {
console.error('Error importing recipients:', error);
resultsDiv.innerHTML = `<div class="error">Failed to import recipients: ${escapeHtml(error.message)}</div>`;
}
}
/**
* Parse CSV text into recipients array
*/
function parseCsv(csvText) {
const lines = csvText.split('\n').filter(line => line.trim());
const recipients = [];
// Skip header row if it exists
const startIndex = lines[0].toLowerCase().includes('recipient_name') ? 1 : 0;
for (let i = startIndex; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Simple CSV parsing (doesn't handle quoted commas)
const parts = line.split(',').map(p => p.trim().replace(/^["']|["']$/g, ''));
if (parts.length >= 2) {
recipients.push({
recipient_name: parts[0],
recipient_email: parts[1],
recipient_title: parts[2] || '',
recipient_organization: parts[3] || '',
notes: parts[4] || ''
});
}
}
return recipients;
}
/**
* Read file as text
*/
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = (e) => reject(e);
reader.readAsText(file);
});
}
/**
* Validate email format
*/
function validateEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
/**
* Show message to user
*/
function showMessage(message, type = 'info') {
// Try to use existing message display system
if (typeof window.showMessage === 'function') {
window.showMessage(message, type);
} else {
// Fallback to alert
alert(message);
}
}
// Public API
return {
init,
loadRecipients,
displayRecipients
};
})();
// Make available globally
window.CustomRecipients = CustomRecipients;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,365 @@
/**
* Email Testing Interface
* Handles email preview, testing, and logging functionality
*/
class EmailTesting {
constructor() {
this.apiClient = new APIClient();
this.currentFilter = 'all'; // 'all', 'test', 'live'
this.logLimit = 50;
this.logOffset = 0;
}
/**
* Initialize the email testing interface
*/
async init() {
this.bindEvents();
await this.loadConfiguration();
await this.loadEmailLogs();
}
/**
* Bind event listeners to UI elements
*/
bindEvents() {
// Quick test buttons
document.getElementById('quick-test-btn').addEventListener('click', () => this.sendQuickTest());
document.getElementById('smtp-test-btn').addEventListener('click', () => this.testSMTPConnection());
// Email form buttons
document.getElementById('preview-btn').addEventListener('click', () => this.previewEmail());
document.getElementById('send-test-btn').addEventListener('click', () => this.sendTestEmail());
// Log control buttons
document.getElementById('refresh-logs-btn').addEventListener('click', () => this.loadEmailLogs());
document.getElementById('filter-test-btn').addEventListener('click', () => this.filterLogs('test'));
document.getElementById('filter-all-btn').addEventListener('click', () => this.filterLogs('all'));
// Form validation
document.getElementById('email-test-form').addEventListener('input', () => this.validateForm());
}
/**
* Send a quick test email with default content
*/
async sendQuickTest() {
const button = document.getElementById('quick-test-btn');
button.disabled = true;
button.textContent = 'Sending...';
try {
const response = await this.apiClient.post('/api/emails/test', {
subject: 'Quick Test Email',
message: 'This is a quick test email sent from the BNKops Influence Campaign Tool email testing interface.'
});
if (response.success) {
this.showMessage(`Test email sent successfully to ${response.sentTo}`, 'success');
await this.loadEmailLogs(); // Refresh logs
} else {
this.showMessage(`Failed to send test email: ${response.message}`, 'error');
}
} catch (error) {
this.showMessage(`Error sending test email: ${error.message}`, 'error');
} finally {
button.disabled = false;
button.textContent = 'Send Quick Test Email';
}
}
/**
* Test SMTP connection
*/
async testSMTPConnection() {
const button = document.getElementById('smtp-test-btn');
button.disabled = true;
button.textContent = 'Testing...';
try {
// Note: This endpoint would need to be added to the API
const response = await this.apiClient.get('/api/test-smtp');
if (response.success) {
this.showMessage('SMTP connection test successful', 'success');
} else {
this.showMessage(`SMTP connection failed: ${response.message}`, 'error');
}
} catch (error) {
this.showMessage(`SMTP test error: ${error.message}`, 'error');
} finally {
button.disabled = false;
button.textContent = 'Test SMTP Connection';
}
}
/**
* Preview email content
*/
async previewEmail() {
const formData = this.getFormData();
if (!formData) return;
const button = document.getElementById('preview-btn');
button.disabled = true;
try {
const recipientEmail = formData.recipientEmail || 'recipient@example.com';
const response = await this.apiClient.post('/api/emails/preview', {
recipientEmail: recipientEmail,
subject: formData.subject,
message: formData.message
});
if (response.success) {
this.displayEmailPreview(response.preview, response.html);
} else {
this.showMessage(`Preview failed: ${response.message}`, 'error');
}
} catch (error) {
this.showMessage(`Preview error: ${error.message}`, 'error');
} finally {
button.disabled = false;
}
}
/**
* Send test email
*/
async sendTestEmail() {
const formData = this.getFormData();
if (!formData) return;
const button = document.getElementById('send-test-btn');
button.disabled = true;
button.textContent = 'Sending...';
try {
const response = await this.apiClient.post('/api/emails/test', {
subject: formData.subject,
message: formData.message
});
if (response.success) {
this.showMessage(`Test email sent successfully to ${response.sentTo}`, 'success');
await this.loadEmailLogs(); // Refresh logs
} else {
this.showMessage(`Failed to send test email: ${response.message}`, 'error');
}
} catch (error) {
this.showMessage(`Error sending test email: ${error.message}`, 'error');
} finally {
button.disabled = false;
button.textContent = 'Send Test Email';
}
}
/**
* Get form data and validate
*/
getFormData() {
const subject = document.getElementById('test-subject').value.trim();
const message = document.getElementById('test-message').value.trim();
const recipientEmail = document.getElementById('test-recipient').value.trim();
if (!subject || !message) {
this.showMessage('Please fill in subject and message', 'error');
return null;
}
return {
subject,
message,
recipientEmail: recipientEmail || null
};
}
/**
* Validate form and update button states
*/
validateForm() {
const subject = document.getElementById('test-subject').value.trim();
const message = document.getElementById('test-message').value.trim();
const isValid = subject && message;
document.getElementById('preview-btn').disabled = !isValid;
document.getElementById('send-test-btn').disabled = !isValid;
}
/**
* Display email preview
*/
displayEmailPreview(preview, html) {
const previewDiv = document.getElementById('email-preview');
previewDiv.classList.remove('empty');
const testModeWarning = preview.testMode ?
`<div style="background: #fff3cd; color: #856404; padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<strong>TEST MODE:</strong> Email will be redirected to ${preview.redirectTo}
</div>` : '';
previewDiv.innerHTML = `
${testModeWarning}
<div style="margin-bottom: 15px;">
<strong>From:</strong> ${preview.from}<br>
<strong>To:</strong> ${preview.to}<br>
<strong>Subject:</strong> ${preview.subject}<br>
<strong>Timestamp:</strong> ${new Date(preview.timestamp).toLocaleString()}
</div>
<div style="border-top: 1px solid #ccc; padding-top: 15px;">
<strong>Message Content:</strong>
<div style="margin-top: 10px; padding: 10px; background: #f8f9fa; border-radius: 4px;">
${html}
</div>
</div>
`;
}
/**
* Load and display email logs
*/
async loadEmailLogs() {
const logsDiv = document.getElementById('email-logs');
logsDiv.innerHTML = '<div class="loading">Loading email logs...</div>';
try {
const params = new URLSearchParams();
params.append('limit', this.logLimit);
params.append('offset', this.logOffset);
if (this.currentFilter === 'test') {
params.append('testMode', 'true');
} else if (this.currentFilter === 'live') {
params.append('testMode', 'false');
}
const response = await this.apiClient.get(`/api/emails/logs?${params.toString()}`);
if (response.success) {
this.displayEmailLogs(response.logs);
} else {
logsDiv.innerHTML = `<div class="error-message">Failed to load logs: ${response.message}</div>`;
}
} catch (error) {
logsDiv.innerHTML = `<div class="error-message">Error loading logs: ${error.message}</div>`;
}
}
/**
* Display email logs
*/
displayEmailLogs(logs) {
const logsDiv = document.getElementById('email-logs');
if (!logs || logs.length === 0) {
logsDiv.innerHTML = '<div style="text-align: center; color: #6c757d; padding: 20px;">No email logs found</div>';
return;
}
logsDiv.innerHTML = logs.map(log => {
const statusClass = log.Status === 'sent' ? 'sent' : 'failed';
const testModeClass = log['Test Mode'] ? 'test-mode' : '';
const statusIndicator = log['Test Mode'] ? 'test' : statusClass;
return `
<div class="log-entry ${statusClass} ${testModeClass}">
<div style="display: flex; justify-content: between; align-items: flex-start;">
<div style="flex: 1;">
<strong>${log.Subject}</strong>
<div>To: ${log.Recipient}</div>
${log['Actual Recipient'] && log['Actual Recipient'] !== log.Recipient ?
`<div style="color: #856404;">Actually sent to: ${log['Actual Recipient']}</div>` : ''}
${log.Error ? `<div style="color: #dc3545;">Error: ${log.Error}</div>` : ''}
</div>
<div style="text-align: right;">
<span class="status-indicator status-${statusIndicator}">${log.Status}</span>
${log['Test Mode'] ? '<span class="status-indicator status-test" style="margin-left: 5px;">TEST</span>' : ''}
</div>
</div>
<div class="log-meta">
${log['Sent At'] ? new Date(log['Sent At']).toLocaleString() :
(log.CreatedAt ? new Date(log.CreatedAt).toLocaleString() : 'Unknown time')}
${log['Message ID'] ? ` • ID: ${log['Message ID']}` : ''}
</div>
</div>
`;
}).join('');
}
/**
* Filter logs by type
*/
async filterLogs(filter) {
this.currentFilter = filter;
this.logOffset = 0; // Reset offset when filtering
// Update button states
document.getElementById('filter-all-btn').classList.toggle('btn-success', filter === 'all');
document.getElementById('filter-test-btn').classList.toggle('btn-warning', filter === 'test');
await this.loadEmailLogs();
}
/**
* Load and display current configuration
*/
async loadConfiguration() {
const configDiv = document.getElementById('config-status');
// Since we don't have a dedicated config endpoint, we'll show env-based info
const isTestMode = true; // Assuming test mode based on .env
const testRecipient = 'admin@example.com'; // From .env
configDiv.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div>
<h4>Email Test Mode</h4>
<div style="color: ${isTestMode ? '#856404' : '#155724'};">
${isTestMode ? 'ENABLED' : 'DISABLED'}
</div>
<small style="color: #6c757d;">
${isTestMode ? 'All emails will be redirected to test recipient' : 'Emails will be sent to actual recipients'}
</small>
</div>
<div>
<h4>Test Email Recipient</h4>
<div>${testRecipient}</div>
<small style="color: #6c757d;">Emails will be sent here in test mode</small>
</div>
</div>
`;
}
/**
* Show success or error message
*/
showMessage(message, type) {
const messageContainer = document.getElementById('message-container');
const messageClass = type === 'success' ? 'success-message' : 'error-message';
const messageDiv = document.createElement('div');
messageDiv.className = messageClass;
messageDiv.textContent = message;
messageDiv.style.position = 'fixed';
messageDiv.style.top = '20px';
messageDiv.style.left = '50%';
messageDiv.style.transform = 'translateX(-50%)';
messageDiv.style.zIndex = '1000';
messageDiv.style.maxWidth = '500px';
messageContainer.appendChild(messageDiv);
// Auto-remove after 5 seconds
setTimeout(() => {
if (messageDiv.parentNode) {
messageDiv.parentNode.removeChild(messageDiv);
}
}, 5000);
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = EmailTesting;
}

View File

@ -0,0 +1,465 @@
/**
* Admin Listmonk Management Functions for Influence System
* Handles admin interface for email list synchronization
*/
// Global variables for admin Listmonk functionality
let syncInProgress = false;
let syncProgressInterval = null;
/**
* Initialize Listmonk admin section
*/
async function initListmonkAdmin() {
await refreshListmonkStatus();
await loadListmonkStats();
}
/**
* Refresh the Listmonk sync status display
*/
async function refreshListmonkStatus() {
console.log('🔄 Refreshing Listmonk status...');
try {
const response = await fetch('/api/listmonk/status', {
credentials: 'include'
});
console.log('📡 Status response:', response.status, response.statusText);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const status = await response.json();
console.log('📊 Status data:', status);
updateStatusDisplay(status);
} catch (error) {
console.error('Failed to refresh Listmonk status:', error);
updateStatusDisplay({
enabled: false,
connected: false,
lastError: `Status check failed: ${error.message}`
});
}
}
/**
* Update the status display in the admin panel
*/
function updateStatusDisplay(status) {
console.log('🎨 Updating status display with:', status);
const connectionStatus = document.getElementById('connection-status');
const autosyncStatus = document.getElementById('autosync-status');
const lastError = document.getElementById('last-error');
console.log('🔍 Status elements found:', {
connectionStatus: !!connectionStatus,
autosyncStatus: !!autosyncStatus,
lastError: !!lastError
});
if (connectionStatus) {
if (status.enabled && status.connected) {
connectionStatus.innerHTML = '✅ <span style="color: #27ae60;">Connected</span>';
} else if (status.enabled) {
connectionStatus.innerHTML = '❌ <span style="color: #e74c3c;">Connection Failed</span>';
} else {
connectionStatus.innerHTML = '⭕ <span style="color: #95a5a6;">Disabled</span>';
}
console.log('✅ Connection status updated:', connectionStatus.innerHTML);
}
if (autosyncStatus) {
if (status.enabled) {
autosyncStatus.innerHTML = '✅ <span style="color: #27ae60;">Enabled</span>';
} else {
autosyncStatus.innerHTML = '⭕ <span style="color: #95a5a6;">Disabled</span>';
}
console.log('✅ Auto-sync status updated:', autosyncStatus.innerHTML);
}
if (lastError) {
if (status.lastError) {
lastError.style.display = 'block';
lastError.innerHTML = `<strong>⚠️ Last Error:</strong> ${escapeHtml(status.lastError)}`;
} else {
lastError.style.display = 'none';
}
console.log('✅ Last error updated:', lastError.innerHTML);
}
}
/**
* Load and display Listmonk list statistics
*/
async function loadListmonkStats() {
try {
const response = await fetch('/api/listmonk/stats', {
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('📊 Stats API response:', data);
if (data.success && data.stats) {
displayListStats(data.stats);
} else {
console.error('Stats API returned unsuccessful response:', data);
displayListStats([]);
}
} catch (error) {
console.error('Failed to load Listmonk stats:', error);
}
}
/**
* Display list statistics in the admin panel
*/
function displayListStats(stats) {
const statsSection = document.getElementById('listmonk-stats-section');
if (!statsSection) return;
console.log('📊 displayListStats called with:', stats, 'Type:', typeof stats);
// Ensure stats is an array
const statsArray = Array.isArray(stats) ? stats : [];
console.log('📊 Stats array after conversion:', statsArray, 'Length:', statsArray.length);
// Clear existing stats
const existingStats = statsSection.querySelector('.stats-list');
if (existingStats) {
existingStats.remove();
}
// Create stats display
const statsList = document.createElement('div');
statsList.className = 'stats-list';
statsList.style.cssText = 'display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem;';
if (statsArray.length === 0) {
statsList.innerHTML = '<p style="color: #666; text-align: center; grid-column: 1/-1;">No email lists found or sync is disabled</p>';
} else {
statsArray.forEach(list => {
const statCard = document.createElement('div');
statCard.style.cssText = 'background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);';
statCard.innerHTML = `
<h4 style="margin: 0 0 0.5rem 0; font-size: 0.9rem; opacity: 0.9;">${escapeHtml(list.name)}</h4>
<p style="margin: 0; font-size: 2rem; font-weight: bold;">${list.subscriberCount || 0}</p>
<p style="margin: 0.25rem 0 0 0; font-size: 0.85rem; opacity: 0.9;">subscribers</p>
`;
statsList.appendChild(statCard);
});
}
statsSection.appendChild(statsList);
}
/**
* Sync data to Listmonk
* @param {string} type - 'participants', 'recipients', or 'all'
*/
async function syncToListmonk(type) {
if (syncInProgress) {
showNotification('Sync already in progress', 'warning');
return;
}
syncInProgress = true;
const progressSection = document.getElementById('sync-progress');
const resultsDiv = document.getElementById('sync-results');
const progressBar = document.getElementById('sync-progress-bar');
// Show progress section
if (progressSection) {
progressSection.style.display = 'block';
}
// Reset progress
if (progressBar) {
progressBar.style.width = '0%';
progressBar.textContent = '0%';
}
if (resultsDiv) {
resultsDiv.innerHTML = '<div class="sync-result info"><strong>⏳ Starting sync...</strong></div>';
}
// Disable sync buttons
const buttons = document.querySelectorAll('.sync-buttons .btn');
buttons.forEach(btn => {
btn.disabled = true;
btn.style.opacity = '0.6';
});
// Simulate progress
let progress = 0;
const progressInterval = setInterval(() => {
if (progress < 90) {
progress += Math.random() * 10;
if (progressBar) {
progressBar.style.width = `${Math.min(progress, 90)}%`;
progressBar.textContent = `${Math.floor(Math.min(progress, 90))}%`;
}
}
}, 200);
try {
let endpoint = '/api/listmonk/sync/';
let syncName = '';
switch(type) {
case 'participants':
endpoint += 'participants';
syncName = 'Campaign Participants';
break;
case 'recipients':
endpoint += 'recipients';
syncName = 'Custom Recipients';
break;
case 'all':
endpoint += 'all';
syncName = 'All Data';
break;
default:
throw new Error('Invalid sync type');
}
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'include'
});
clearInterval(progressInterval);
if (progressBar) {
progressBar.style.width = '100%';
progressBar.textContent = '100%';
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
displaySyncResults(result, resultsDiv);
// Refresh stats after successful sync
await loadListmonkStats();
} catch (error) {
clearInterval(progressInterval);
console.error('Sync failed:', error);
if (resultsDiv) {
resultsDiv.innerHTML = `
<div class="sync-result error" style="background: #f8d7da; color: #721c24; padding: 1rem; border-radius: 8px; border-left: 4px solid #e74c3c;">
<strong> Sync Failed</strong>
<p style="margin: 0.5rem 0 0 0;">${escapeHtml(error.message)}</p>
</div>
`;
}
} finally {
syncInProgress = false;
// Re-enable sync buttons
buttons.forEach(btn => {
btn.disabled = false;
btn.style.opacity = '1';
});
}
}
/**
* Display sync results in the admin panel
*/
function displaySyncResults(result, resultsDiv) {
if (!resultsDiv) return;
let html = '';
if (result.success) {
html += `<div class="sync-result success" style="background: #d4edda; color: #155724; padding: 1rem; border-radius: 8px; border-left: 4px solid #27ae60; margin-bottom: 1rem;">
<strong> ${escapeHtml(result.message || 'Sync completed successfully')}</strong>
</div>`;
if (result.results) {
// Handle different result structures
if (result.results.participants || result.results.customRecipients) {
// Multi-type sync (all)
if (result.results.participants) {
html += formatSyncResults('Campaign Participants', result.results.participants);
}
if (result.results.customRecipients) {
html += formatSyncResults('Custom Recipients', result.results.customRecipients);
}
} else {
// Single type sync
html += formatSyncResults('Results', result.results);
}
}
} else {
html += `<div class="sync-result error" style="background: #f8d7da; color: #721c24; padding: 1rem; border-radius: 8px; border-left: 4px solid #e74c3c;">
<strong> Sync Failed</strong>
<p style="margin: 0.5rem 0 0 0;">${escapeHtml(result.error || result.message || 'Unknown error')}</p>
</div>`;
}
resultsDiv.innerHTML = html;
}
/**
* Format sync results for display
*/
function formatSyncResults(type, results) {
let html = `<div class="sync-result info" style="background: #e3f2fd; padding: 1rem; border-radius: 8px; border-left: 4px solid #2196f3; margin-bottom: 0.5rem;">
<strong>📊 ${escapeHtml(type)}:</strong>
<span style="color: #27ae60; font-weight: bold;">${results.success} succeeded</span>,
<span style="color: #e74c3c; font-weight: bold;">${results.failed} failed</span>
(${results.total} total)
</div>`;
// Show errors if any
if (results.errors && results.errors.length > 0) {
const maxErrors = 5; // Show max 5 errors
const errorCount = results.errors.length;
html += `<div class="sync-result warning" style="background: #fff3cd; color: #856404; padding: 1rem; border-radius: 8px; border-left: 4px solid #f39c12; margin-bottom: 0.5rem;">
<strong> Errors (showing ${Math.min(errorCount, maxErrors)} of ${errorCount}):</strong>
<ul style="margin: 0.5rem 0 0 0; padding-left: 1.5rem;">`;
results.errors.slice(0, maxErrors).forEach(error => {
html += `<li style="margin: 0.25rem 0;">${escapeHtml(error.email || 'Unknown')}: ${escapeHtml(error.error || 'Unknown error')}</li>`;
});
html += '</ul></div>';
}
return html;
}
/**
* Test Listmonk connection
*/
async function testListmonkConnection() {
try {
const response = await fetch('/api/listmonk/test-connection', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.success) {
showNotification('✅ Listmonk connection successful!', 'success');
await refreshListmonkStatus();
} else {
showNotification(`❌ Connection failed: ${result.message}`, 'error');
}
} catch (error) {
console.error('Connection test failed:', error);
showNotification(`❌ Connection test failed: ${error.message}`, 'error');
}
}
/**
* Reinitialize Listmonk lists
*/
async function reinitializeListmonk() {
if (!confirm('⚠️ This will recreate all email lists. Are you sure?')) {
return;
}
try {
const response = await fetch('/api/listmonk/reinitialize', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.success) {
showNotification('✅ Listmonk lists reinitialized successfully!', 'success');
await refreshListmonkStatus();
await loadListmonkStats();
} else {
showNotification(`❌ Reinitialization failed: ${result.message}`, 'error');
}
} catch (error) {
console.error('Reinitialization failed:', error);
showNotification(`❌ Reinitialization failed: ${error.message}`, 'error');
}
}
/**
* Utility function to escape HTML
*/
function escapeHtml(text) {
if (typeof text !== 'string') return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Show notification message
*/
function showNotification(message, type = 'info') {
const messageContainer = document.getElementById('message-container');
if (!messageContainer) {
// Fallback to alert if container not found
alert(message);
return;
}
const alertClass = type === 'success' ? 'message-success' :
type === 'error' ? 'message-error' :
'message-info';
messageContainer.className = alertClass;
messageContainer.textContent = message;
messageContainer.classList.remove('hidden');
// Auto-hide after 5 seconds
setTimeout(() => {
messageContainer.classList.add('hidden');
}, 5000);
}
// Initialize Listmonk admin when tab is activated
document.addEventListener('DOMContentLoaded', () => {
// Watch for tab changes
const listmonkTab = document.querySelector('[data-tab="listmonk"]');
if (listmonkTab) {
listmonkTab.addEventListener('click', () => {
// Initialize on first load
if (!listmonkTab.dataset.initialized) {
initListmonkAdmin();
listmonkTab.dataset.initialized = 'true';
}
});
}
});
// Export functions for global use
window.syncToListmonk = syncToListmonk;
window.refreshListmonkStatus = refreshListmonkStatus;
window.testListmonkConnection = testListmonkConnection;
window.reinitializeListmonk = reinitializeListmonk;
window.initListmonkAdmin = initListmonkAdmin;

View File

@ -0,0 +1,87 @@
// Login page specific functionality
document.addEventListener('DOMContentLoaded', function() {
const loginForm = document.getElementById('login-form');
const loginBtn = document.getElementById('login-btn');
const loginText = document.getElementById('login-text');
const loading = document.querySelector('.loading');
const errorMessage = document.getElementById('error-message');
// Check if already logged in
checkSession();
loginForm.addEventListener('submit', async function(e) {
e.preventDefault();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
if (!email || !password) {
showError('Please enter both email and password');
return;
}
setLoading(true);
hideError();
try {
const response = await apiClient.post('/auth/login', {
email,
password
});
if (response.success) {
// Redirect based on user role
if (response.user && response.user.isAdmin) {
window.location.href = '/admin.html';
} else {
window.location.href = '/dashboard.html';
}
} else {
showError(response.error || 'Login failed');
}
} catch (error) {
console.error('Login error:', error);
showError(error.message || 'Login failed. Please try again.');
} finally {
setLoading(false);
}
});
async function checkSession() {
try {
const response = await apiClient.get('/auth/session');
if (response.authenticated && response.user) {
// Already logged in, redirect based on user role
if (response.user.isAdmin) {
window.location.href = '/admin.html';
} else {
window.location.href = '/dashboard.html';
}
}
} catch (error) {
// Not logged in, continue with login form
console.log('Not logged in');
}
}
function setLoading(isLoading) {
loginBtn.disabled = isLoading;
loginText.style.display = isLoading ? 'none' : 'inline';
loading.style.display = isLoading ? 'block' : 'none';
}
function showError(message) {
errorMessage.textContent = message;
errorMessage.style.display = 'block';
}
function hideError() {
errorMessage.style.display = 'none';
}
// Check for URL parameters
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('expired') === 'true') {
showError('Your session has expired. Please log in again.');
}
});

View File

@ -0,0 +1,304 @@
// Main Application Module
class MainApp {
constructor() {
this.init();
}
async init() {
// Initialize message display system
window.messageDisplay = new MessageDisplay();
// Check API health on startup
this.checkAPIHealth();
// Initialize postal lookup immediately (always show it first)
this.postalLookup = new PostalLookup(this.updateRepresentatives.bind(this));
// Check for highlighted campaign FIRST (before campaigns grid)
await this.checkHighlightedCampaign();
// Initialize campaigns grid AFTER highlighted campaign loads
this.campaignsGrid = new CampaignsGrid();
await this.campaignsGrid.init();
// Add global error handling
window.addEventListener('error', (e) => {
// Only log and show message for actual errors, not null/undefined
if (e.error) {
console.error('Global error:', e.error);
console.error('Error details:', {
message: e.message,
filename: e.filename,
lineno: e.lineno,
colno: e.colno,
error: e.error
});
window.messageDisplay?.show('An unexpected error occurred. Please refresh the page and try again.', 'error');
} else {
// Just log these non-critical errors without showing popup
console.log('Non-critical error event:', {
message: e.message,
filename: e.filename,
lineno: e.lineno,
colno: e.colno,
type: e.type
});
}
});
// Add unhandled promise rejection handling
window.addEventListener('unhandledrejection', (e) => {
if (e.reason) {
console.error('Unhandled promise rejection:', e.reason);
window.messageDisplay?.show('An unexpected error occurred. Please try again.', 'error');
e.preventDefault();
} else {
console.log('Non-critical promise rejection:', e);
}
});
}
async checkAPIHealth() {
try {
await window.apiClient.checkHealth();
console.log('API health check passed');
} catch (error) {
console.error('API health check failed:', error);
window.messageDisplay.show('Connection to server failed. Please check your internet connection and try again.', 'error');
}
}
async checkHighlightedCampaign() {
try {
const response = await fetch('/api/public/highlighted-campaign');
if (!response.ok) {
if (response.status === 404) {
// No highlighted campaign, show normal postal code lookup
return false;
}
throw new Error('Failed to fetch highlighted campaign');
}
const data = await response.json();
if (data.success && data.campaign) {
this.displayHighlightedCampaign(data.campaign);
return true;
}
return false;
} catch (error) {
console.error('Error checking for highlighted campaign:', error);
// Continue with normal postal code lookup if there's an error
return false;
}
}
displayHighlightedCampaign(campaign) {
const highlightedSection = document.getElementById('highlighted-campaign-section');
const highlightedContainer = document.getElementById('highlighted-campaign-container');
if (!highlightedSection || !highlightedContainer) return;
// Build the campaign display HTML with cover photo
const coverPhotoStyle = campaign.cover_photo
? `background-image: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.3)), url('/uploads/${campaign.cover_photo}'); background-size: cover; background-position: center;`
: 'background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);';
const statsHTML = [];
if (campaign.show_email_count && campaign.emailCount !== null) {
statsHTML.push(`<div class="stat"><span class="stat-icon">📧</span><strong>${campaign.emailCount}</strong> Emails Sent</div>`);
}
if (campaign.show_call_count && campaign.callCount !== null) {
statsHTML.push(`<div class="stat"><span class="stat-icon">📞</span><strong>${campaign.callCount}</strong> Calls Made</div>`);
}
if (campaign.show_response_count && campaign.responseCount !== null) {
statsHTML.push(`<div class="stat"><span class="stat-icon">✅</span><strong>${campaign.responseCount}</strong> Responses</div>`);
}
const highlightedHTML = `
<div class="highlighted-campaign-container">
${campaign.cover_photo ? `
<div class="highlighted-campaign-header" style="${coverPhotoStyle}">
<div class="highlighted-campaign-badge"> Featured Campaign</div>
<h2>${this.escapeHtml(campaign.title || campaign.name)}</h2>
</div>
` : `
<div class="highlighted-campaign-badge"> Featured Campaign</div>
<h2>${this.escapeHtml(campaign.title || campaign.name)}</h2>
`}
<div class="highlighted-campaign-content">
${campaign.description ? `<p class="campaign-description">${this.escapeHtml(campaign.description)}</p>` : ''}
${statsHTML.length > 0 ? `
<div class="campaign-stats-inline">
${statsHTML.join('')}
</div>
` : ''}
<div class="campaign-cta">
<a href="/campaign/${campaign.slug}" class="btn btn-primary btn-large">
Join This Campaign
</a>
</div>
</div>
</div>
`;
// Insert the HTML
highlightedContainer.innerHTML = highlightedHTML;
// Make section visible but collapsed
highlightedSection.style.display = 'grid';
// Force a reflow to ensure the initial state is applied
const height = highlightedSection.offsetHeight;
console.log('Campaign section initial height:', height);
// Wait a bit longer before starting animation to ensure it's visible
setTimeout(() => {
console.log('Starting campaign expansion animation...');
highlightedSection.classList.add('show');
// Add animation to the container after expansion starts
setTimeout(() => {
const container = highlightedContainer.querySelector('.highlighted-campaign-container');
if (container) {
console.log('Adding visible class to container...');
container.classList.add('visible', 'fade-in-smooth');
}
}, 300);
}, 100);
}
updateRepresentatives(representatives) {
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Message Display System
class MessageDisplay {
constructor() {
this.container = document.getElementById('message-display');
this.timeouts = new Map();
}
show(message, type = 'info', duration = 5000) {
// Clear existing timeout for this container
if (this.timeouts.has(this.container)) {
clearTimeout(this.timeouts.get(this.container));
}
// Set message content and type
this.container.innerHTML = message;
this.container.className = `message-display ${type}`;
this.container.style.display = 'block';
// Auto-hide after duration
const timeout = setTimeout(() => {
this.hide();
}, duration);
this.timeouts.set(this.container, timeout);
// Add click to dismiss
this.container.style.cursor = 'pointer';
this.container.onclick = () => this.hide();
}
hide() {
this.container.style.display = 'none';
this.container.onclick = null;
// Clear timeout
if (this.timeouts.has(this.container)) {
clearTimeout(this.timeouts.get(this.container));
this.timeouts.delete(this.container);
}
}
}
// Utility functions
const Utils = {
// Format postal code consistently
formatPostalCode(postalCode) {
const cleaned = postalCode.replace(/\s/g, '').toUpperCase();
if (cleaned.length === 6) {
return `${cleaned.slice(0, 3)} ${cleaned.slice(3)}`;
}
return cleaned;
},
// Sanitize text input
sanitizeText(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
// Debounce function for input handling
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
// Check if we're on mobile
isMobile() {
return window.innerWidth <= 768;
},
// Format date for display
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-CA', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
};
// Make utils globally available
window.Utils = Utils;
// Initialize app when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.mainApp = new MainApp();
// Initialize campaigns grid
if (typeof CampaignsGrid !== 'undefined') {
window.campaignsGrid = new CampaignsGrid();
window.campaignsGrid.init();
}
// Add some basic accessibility improvements
document.addEventListener('keydown', (e) => {
// Allow Escape to close modals (handled in individual modules)
// Add tab navigation improvements if needed
});
// Add responsive behavior
window.addEventListener('resize', Utils.debounce(() => {
// Handle responsive layout changes if needed
const isMobile = Utils.isMobile();
document.body.classList.toggle('mobile', isMobile);
}, 250));
// Initial mobile class
document.body.classList.toggle('mobile', Utils.isMobile());
});

View File

@ -0,0 +1,166 @@
// Postal Code Lookup Module
class PostalLookup {
constructor() {
this.form = document.getElementById('postal-form');
this.input = document.getElementById('postal-code');
this.refreshBtn = document.getElementById('refresh-btn');
this.loadingDiv = document.getElementById('loading');
this.errorDiv = document.getElementById('error-message');
this.representativesSection = document.getElementById('representatives-section');
this.locationDetails = document.getElementById('location-details');
this.currentPostalCode = null;
this.init();
}
init() {
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
// Only add refresh button listener if it exists
if (this.refreshBtn) {
this.refreshBtn.addEventListener('click', () => this.handleRefresh());
}
this.input.addEventListener('input', (e) => this.formatPostalCode(e));
}
formatPostalCode(e) {
let value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
// Format as A1A 1A1
if (value.length > 3) {
value = value.slice(0, 3) + ' ' + value.slice(3, 6);
}
e.target.value = value;
}
validatePostalCode(postalCode) {
const cleaned = postalCode.replace(/\s/g, '');
// Check format: Letter-Number-Letter Number-Letter-Number
const regex = /^[A-Z]\d[A-Z]\d[A-Z]\d$/;
if (!regex.test(cleaned)) {
return { valid: false, message: 'Please enter a valid postal code format (A1A 1A1)' };
}
// Check if it's an Alberta postal code (starts with T)
if (!cleaned.startsWith('T')) {
return { valid: false, message: 'This tool is designed for Alberta postal codes only (starting with T)' };
}
return { valid: true };
}
showError(message) {
this.errorDiv.textContent = message;
this.errorDiv.style.display = 'block';
this.representativesSection.style.display = 'none';
}
hideError() {
this.errorDiv.style.display = 'none';
}
showLoading() {
this.loadingDiv.style.display = 'block';
this.hideError();
this.representativesSection.style.display = 'none';
}
hideLoading() {
this.loadingDiv.style.display = 'none';
}
async handleSubmit(e) {
e.preventDefault();
const postalCode = this.input.value.trim();
if (!postalCode) {
this.showError('Please enter a postal code');
return;
}
const validation = this.validatePostalCode(postalCode);
if (!validation.valid) {
this.showError(validation.message);
return;
}
await this.lookupRepresentatives(postalCode);
}
async handleRefresh() {
if (!this.currentPostalCode) return;
try {
this.showLoading();
this.refreshBtn.disabled = true;
const data = await window.apiClient.refreshRepresentatives(this.currentPostalCode);
this.displayResults(data);
window.messageDisplay.show('Representatives data refreshed successfully!', 'success');
} catch (error) {
console.error('Refresh failed:', error);
this.showError(`Failed to refresh data: ${error.message}`);
} finally {
this.hideLoading();
this.refreshBtn.disabled = false;
}
}
async lookupRepresentatives(postalCode) {
try {
this.showLoading();
const data = await window.apiClient.getRepresentativesByPostalCode(postalCode);
this.currentPostalCode = postalCode;
// Store postal code globally for call tracking
window.lastLookupPostalCode = postalCode;
this.displayResults(data);
} catch (error) {
console.error('Lookup failed:', error);
this.showError(`Failed to find representatives: ${error.message}`);
} finally {
this.hideLoading();
}
}
displayResults(apiResponse) {
this.hideError();
this.hideLoading();
// Handle the API response structure
const data = apiResponse.data || apiResponse; // Handle both new and old response formats
// Update location info
let locationText = `Postal Code: ${data.postalCode}`;
if (data.location && data.location.city && data.location.province) {
locationText += `${data.location.city}, ${data.location.province}`;
} else if (data.city && data.province) {
locationText += `${data.city}, ${data.province}`;
}
if (data.source || apiResponse.source) {
locationText += ` • Pulled From: ${data.source || apiResponse.source}`;
}
this.locationDetails.textContent = locationText;
// Show representatives
const representatives = data.representatives || [];
console.log('Displaying representatives:', representatives.length, representatives);
window.representativesDisplay.displayRepresentatives(representatives);
// Show section and refresh button
this.representativesSection.style.display = 'block';
this.refreshBtn.style.display = 'inline-block';
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.postalLookup = new PostalLookup();
});

View File

@ -0,0 +1,543 @@
// Representatives Display Module
class RepresentativesDisplay {
constructor() {
this.container = document.getElementById('representatives-container');
}
displayRepresentatives(representatives) {
if (!representatives || representatives.length === 0) {
this.container.innerHTML = `
<div class="rep-category">
<h3>No Representatives Found</h3>
<p>No representatives were found for this postal code. This might be due to:</p>
<ul>
<li>The postal code is not in our database</li>
<li>Temporary API issues</li>
<li>The postal code is not currently assigned to electoral districts</li>
</ul>
<p>Please try again later or verify your postal code.</p>
</div>
`;
return;
}
// Group representatives by level/type
const grouped = this.groupRepresentatives(representatives);
let html = '';
// Order of importance for display
const displayOrder = [
'Federal',
'Provincial',
'Municipal',
'School Board',
'Other'
];
displayOrder.forEach(level => {
if (grouped[level] && grouped[level].length > 0) {
html += this.renderRepresentativeCategory(level, grouped[level]);
}
});
this.container.innerHTML = html;
this.attachEventListeners();
}
groupRepresentatives(representatives) {
const groups = {
'Federal': [],
'Provincial': [],
'Municipal': [],
'School Board': [],
'Other': []
};
representatives.forEach(rep => {
const setName = rep.representative_set_name || '';
const office = rep.elected_office || '';
if (setName.toLowerCase().includes('house of commons') ||
setName.toLowerCase().includes('federal') ||
office.toLowerCase().includes('member of parliament') ||
office.toLowerCase().includes('mp')) {
groups['Federal'].push(rep);
} else if (setName.toLowerCase().includes('provincial') ||
setName.toLowerCase().includes('legislative assembly') ||
setName.toLowerCase().includes('mla') ||
office.toLowerCase().includes('mla')) {
groups['Provincial'].push(rep);
} else if (setName.toLowerCase().includes('municipal') ||
setName.toLowerCase().includes('city council') ||
setName.toLowerCase().includes('mayor') ||
office.toLowerCase().includes('councillor') ||
office.toLowerCase().includes('mayor')) {
groups['Municipal'].push(rep);
} else if (setName.toLowerCase().includes('school') ||
office.toLowerCase().includes('school') ||
office.toLowerCase().includes('trustee')) {
groups['School Board'].push(rep);
} else {
groups['Other'].push(rep);
}
});
return groups;
}
renderRepresentativeCategory(categoryName, representatives) {
const cards = representatives.map(rep => this.renderRepresentativeCard(rep)).join('');
return `
<div class="rep-category">
<h3>${categoryName} Representatives</h3>
<div class="rep-cards">
${cards}
</div>
</div>
`;
}
renderRepresentativeCard(rep) {
const name = rep.name || 'Name not available';
const email = rep.email || null;
const office = rep.elected_office || 'Office not specified';
const district = rep.district_name || 'District not specified';
const party = rep.party_name || 'Party not specified';
const photoUrl = rep.photo_url || null;
// Extract phone numbers from offices array
const phoneNumbers = this.extractPhoneNumbers(rep.offices || []);
const primaryPhone = phoneNumbers.length > 0 ? phoneNumbers[0] : null;
const emailButton = email ?
`<button class="btn btn-primary compose-email"
data-email="${email}"
data-name="${name}"
data-office="${office}"
data-district="${district}">
📧 Send Email
</button>` :
'<span class="text-muted">No email available</span>';
// Add call button if phone number is available
const callButton = primaryPhone ?
`<button class="btn btn-success call-representative"
data-phone="${primaryPhone.number}"
data-office-type="${primaryPhone.type}"
data-name="${name}"
data-office="${office}">
📞 Call
</button>` : '';
// Add visit buttons for all available office addresses
const visitButtons = this.createVisitButtons(rep.offices || [], name, office);
const profileUrl = rep.url ?
`<a href="${rep.url}" target="_blank" class="btn btn-secondary">👤 View Profile</a>` : '';
// Generate initials for fallback
const initials = name.split(' ')
.map(word => word.charAt(0))
.join('')
.toUpperCase()
.slice(0, 2);
const photoElement = photoUrl ?
`<div class="rep-photo">
<img src="${photoUrl}"
alt="${name}"
data-fallback-initials="${initials}"
loading="lazy">
<div class="rep-photo-fallback" style="display: none;">
${initials}
</div>
</div>` :
`<div class="rep-photo">
<div class="rep-photo-fallback">
${initials}
</div>
</div>`;
return `
<div class="rep-card">
${photoElement}
<div class="rep-content">
<div class="rep-header">
<h4>${name}</h4>
</div>
<div class="rep-info">
<p><strong>Office:</strong> ${office}</p>
<p><strong>District:</strong> ${district}</p>
${party !== 'Party not specified' ? `<p><strong>Party:</strong> ${party}</p>` : ''}
${email ? `<p><strong>Email:</strong> ${email}</p>` : ''}
${primaryPhone ? `<p><strong>Phone:</strong> ${primaryPhone.number} ${primaryPhone.type ? `(${primaryPhone.type})` : ''}</p>` : ''}
</div>
<div class="rep-actions">
${emailButton}
${callButton}
${profileUrl}
</div>
${visitButtons ? `<div class="rep-visit-buttons">${visitButtons}</div>` : ''}
</div>
</div>
`;
}
extractPhoneNumbers(offices) {
const phoneNumbers = [];
if (Array.isArray(offices)) {
// Priority order for office types (prefer local over remote)
const officePriorities = ['constituency', 'district', 'local', 'regional', 'legislature'];
// First, try to find offices with Alberta addresses (for MPs)
const albertaOffices = offices.filter(office => {
const address = office.postal || office.address || '';
return address.toLowerCase().includes('alberta') ||
address.toLowerCase().includes(' ab ') ||
address.toLowerCase().includes('edmonton') ||
address.toLowerCase().includes('calgary') ||
address.toLowerCase().includes('red deer') ||
address.toLowerCase().includes('lethbridge') ||
address.toLowerCase().includes('medicine hat');
});
// Add phone numbers from Alberta offices first
if (albertaOffices.length > 0) {
for (const priority of officePriorities) {
const priorityOffice = albertaOffices.find(office =>
office.type === priority && office.tel
);
if (priorityOffice) {
phoneNumbers.push({
number: priorityOffice.tel,
type: priorityOffice.type || 'office'
});
}
}
// Add any remaining Alberta office phone numbers
albertaOffices.forEach(office => {
if (office.tel && !phoneNumbers.find(p => p.number === office.tel)) {
phoneNumbers.push({
number: office.tel,
type: office.type || 'office'
});
}
});
}
// Then add phone numbers from other offices by priority
for (const priority of officePriorities) {
const priorityOffice = offices.find(office =>
office.type === priority && office.tel &&
!phoneNumbers.find(p => p.number === office.tel)
);
if (priorityOffice) {
phoneNumbers.push({
number: priorityOffice.tel,
type: priorityOffice.type || 'office'
});
}
}
// Finally, add any remaining phone numbers
offices.forEach(office => {
if (office.tel && !phoneNumbers.find(p => p.number === office.tel)) {
phoneNumbers.push({
number: office.tel,
type: office.type || 'office'
});
}
});
}
return phoneNumbers;
}
createVisitButtons(offices, repName, repOffice) {
if (!Array.isArray(offices) || offices.length === 0) {
return '';
}
const validOffices = offices.filter(office => office.postal || office.address);
if (validOffices.length === 0) {
return '';
}
// Sort offices by priority (local first)
const sortedOffices = validOffices.sort((a, b) => {
const aAddress = (a.postal || a.address || '').toLowerCase();
const bAddress = (b.postal || b.address || '').toLowerCase();
// Check if address is in Alberta
const aIsAlberta = aAddress.includes('alberta') || aAddress.includes(' ab ') ||
aAddress.includes('edmonton') || aAddress.includes('calgary');
const bIsAlberta = bAddress.includes('alberta') || bAddress.includes(' ab ') ||
bAddress.includes('edmonton') || bAddress.includes('calgary');
if (aIsAlberta && !bIsAlberta) return -1;
if (!aIsAlberta && bIsAlberta) return 1;
// If both are Alberta or both are not, prefer constituency over legislature
const typePriority = { 'constituency': 1, 'district': 2, 'local': 3, 'regional': 4, 'legislature': 5 };
const aPriority = typePriority[a.type] || 6;
const bPriority = typePriority[b.type] || 6;
return aPriority - bPriority;
});
return sortedOffices.map(office => {
const address = office.postal || office.address;
const officeType = this.getOfficeTypeLabel(office.type, address);
const isLocal = this.isLocalAddress(address);
return `
<button class="btn btn-sm btn-secondary visit-office"
data-address="${address}"
data-name="${repName}"
data-office="${repOffice}"
title="Visit ${officeType} office">
🗺 ${officeType}${isLocal ? ' 📍' : ''}
<small class="office-location">${this.getShortAddress(address)}</small>
</button>
`;
}).join('');
}
getOfficeTypeLabel(type, address) {
if (!type) {
// Try to determine type from address
const addr = address.toLowerCase();
if (addr.includes('ottawa') || addr.includes('parliament') || addr.includes('house of commons')) {
return 'Ottawa';
} else if (addr.includes('legislature') || addr.includes('provincial')) {
return 'Legislature';
} else if (addr.includes('city hall')) {
return 'City Hall';
}
return 'Office';
}
const typeLabels = {
'constituency': 'Local Office',
'district': 'District Office',
'local': 'Local Office',
'regional': 'Regional Office',
'legislature': 'Legislature'
};
return typeLabels[type] || type.charAt(0).toUpperCase() + type.slice(1);
}
isLocalAddress(address) {
const addr = address.toLowerCase();
return addr.includes('alberta') || addr.includes(' ab ') ||
addr.includes('edmonton') || addr.includes('calgary') ||
addr.includes('red deer') || addr.includes('lethbridge') ||
addr.includes('medicine hat');
}
getShortAddress(address) {
// Clean the address first
const cleaned = this.cleanAddress(address);
// Extract city and province/state for short display
const parts = cleaned.split(',').map(p => p.trim()).filter(p => p);
if (parts.length >= 2) {
const city = parts[parts.length - 2];
const province = parts[parts.length - 1];
return `${city}, ${province}`;
}
// Fallback: just show the cleaned address
return parts[0] || cleaned;
}
cleanAddress(address) {
if (!address) return '';
// Split by newlines and process each line
const lines = address.split('\n').map(line => line.trim()).filter(line => line);
// Remove common prefixes and metadata
const filteredLines = lines.filter(line => {
const lower = line.toLowerCase();
// Skip lines that are just descriptive text
return !lower.startsWith('main office') &&
!lower.startsWith('constituency office') &&
!lower.startsWith('legislature office') &&
!lower.startsWith('district office') &&
!lower.startsWith('local office') &&
!lower.startsWith('office:') &&
!line.match(/^[a-z\s]+\s*-\s*/i); // Remove "Main office - City" patterns
});
// If we filtered everything out, use original lines minus obvious prefixes
const addressLines = filteredLines.length > 0 ? filteredLines : lines.slice(1);
// Join remaining lines with commas
return addressLines.join(', ').trim();
}
attachEventListeners() {
// Email compose buttons
const composeButtons = this.container.querySelectorAll('.compose-email');
composeButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
const email = button.dataset.email;
const name = button.dataset.name;
const office = button.dataset.office;
const district = button.dataset.district;
// Find the closest rep-card ancestor
const repCard = button.closest('.rep-card');
if (window.emailComposer) {
window.emailComposer.openModal({
email,
name,
office,
district
}, repCard); // Pass the card element
}
});
});
// Call buttons
const callButtons = this.container.querySelectorAll('.call-representative');
callButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
const phone = button.dataset.phone;
const name = button.dataset.name;
const office = button.dataset.office;
const officeType = button.dataset.officeType || '';
this.handleCallClick(phone, name, office, officeType);
});
});
// Visit buttons (for office addresses)
const visitButtons = this.container.querySelectorAll('.visit-office');
visitButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
const address = button.dataset.address;
const name = button.dataset.name;
const office = button.dataset.office;
this.handleVisitClick(address, name, office);
});
});
// Photo error handling (fallback to initials)
const photos = this.container.querySelectorAll('.rep-photo img');
photos.forEach(img => {
img.addEventListener('error', function() {
this.style.display = 'none';
const fallback = this.parentElement.querySelector('.rep-photo-fallback');
if (fallback) {
fallback.style.display = 'flex';
}
});
});
}
handleCallClick(phone, name, office, officeType) {
// Clean the phone number for tel: link (remove spaces, dashes, parentheses)
const cleanPhone = phone.replace(/[\s\-\(\)]/g, '');
// Create tel: link
const telLink = `tel:${cleanPhone}`;
// Show confirmation dialog with formatted information
const officeInfo = officeType ? ` (${officeType} office)` : '';
const message = `Call ${name}${officeInfo}?\n\nPhone: ${phone}`;
if (confirm(message)) {
// Attempt to initiate the call
window.location.href = telLink;
// Track the call
this.trackCall(phone, name, office, officeType);
}
}
async trackCall(phone, name, office, officeType) {
try {
await fetch('/api/track-call', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
representativeName: name,
representativeTitle: office || '',
phoneNumber: phone,
officeType: officeType || '',
postalCode: window.lastLookupPostalCode || null,
userEmail: null,
userName: null
})
});
} catch (error) {
console.error('Failed to track call:', error);
// Don't show error to user - tracking is non-critical
}
}
handleVisitClick(address, name, office) {
// Clean and format the address for URL encoding
const cleanAddress = this.cleanAddress(address);
// Show confirmation dialog
const message = `Open directions to ${name}'s office?\n\nAddress: ${cleanAddress}`;
if (confirm(message)) {
// Create maps URL - this will work on most platforms
// For mobile devices, it will open the default maps app
// For desktop, it will open Google Maps in browser
const encodedAddress = encodeURIComponent(cleanAddress);
// Try different map services based on user agent
const userAgent = navigator.userAgent.toLowerCase();
let mapsUrl;
if (userAgent.includes('iphone') || userAgent.includes('ipad')) {
// iOS - use Apple Maps
mapsUrl = `maps://maps.apple.com/?q=${encodedAddress}`;
// Fallback to Google Maps if Apple Maps doesn't work
setTimeout(() => {
window.open(`https://www.google.com/maps/search/${encodedAddress}`, '_blank');
}, 500);
window.location.href = mapsUrl;
} else if (userAgent.includes('android')) {
// Android - use Google Maps app if available
mapsUrl = `geo:0,0?q=${encodedAddress}`;
// Fallback to Google Maps web
setTimeout(() => {
window.open(`https://www.google.com/maps/search/${encodedAddress}`, '_blank');
}, 500);
window.location.href = mapsUrl;
} else {
// Desktop or other - open Google Maps in new tab
mapsUrl = `https://www.google.com/maps/search/${encodedAddress}`;
window.open(mapsUrl, '_blank');
}
}
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.representativesDisplay = new RepresentativesDisplay();
});

View File

@ -0,0 +1,925 @@
/**
* Representatives Map Module
* Handles map initialization, office location display, and popup cards
*/
// Map state
let representativesMap = null;
let representativeMarkers = [];
let currentPostalCode = null;
// Office location icons
const officeIcons = {
federal: L.divIcon({
className: 'office-marker federal',
html: '<div class="marker-content">🏛️</div>',
iconSize: [40, 40],
iconAnchor: [20, 40],
popupAnchor: [0, -40]
}),
provincial: L.divIcon({
className: 'office-marker provincial',
html: '<div class="marker-content">🏢</div>',
iconSize: [40, 40],
iconAnchor: [20, 40],
popupAnchor: [0, -40]
}),
municipal: L.divIcon({
className: 'office-marker municipal',
html: '<div class="marker-content">🏛️</div>',
iconSize: [40, 40],
iconAnchor: [20, 40],
popupAnchor: [0, -40]
})
};
// Initialize the representatives map
function initializeRepresentativesMap() {
const mapContainer = document.getElementById('main-map');
if (!mapContainer) {
console.warn('Map container not found');
return;
}
// Avoid double initialization
if (representativesMap) {
console.log('Map already initialized, invalidating size instead');
representativesMap.invalidateSize();
return;
}
// Check if Leaflet is available
if (typeof L === 'undefined') {
console.error('Leaflet (L) is not defined. Map initialization failed.');
return;
}
// We'll initialize the map even if not visible, then invalidate size when needed
console.log('Initializing representatives map...');
// Center on Alberta
representativesMap = L.map('main-map').setView([53.9333, -116.5765], 6);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
minZoom: 2
}).addTo(representativesMap);
// Trigger size invalidation after a brief moment to ensure proper rendering
setTimeout(() => {
if (representativesMap) {
representativesMap.invalidateSize();
}
}, 200);
}
// Clear all representative markers from the map
function clearRepresentativeMarkers() {
representativeMarkers.forEach(marker => {
representativesMap.removeLayer(marker);
});
representativeMarkers = [];
}
// Add representative offices to the map
async function displayRepresentativeOffices(representatives, postalCode) {
// Initialize map if not already done
if (!representativesMap) {
console.log('Map not initialized, initializing now...');
initializeRepresentativesMap();
}
if (!representativesMap) {
console.error('Failed to initialize map');
return;
}
clearRepresentativeMarkers();
currentPostalCode = postalCode;
const validOffices = [];
let bounds = [];
console.log('Processing representatives for map display:', representatives.length);
// Show geocoding progress
showMapMessage(`Locating ${representatives.length} office${representatives.length > 1 ? 's' : ''}...`);
// Group representatives by office location to handle shared addresses
const locationGroups = new Map();
// Process all representatives and geocode their offices
for (const rep of representatives) {
console.log(`Processing representative:`, rep.name, rep.representative_set_name);
// Get office location (now async for geocoding)
const offices = await getOfficeLocations(rep);
console.log(`Found ${offices.length} offices for ${rep.name}:`, offices);
offices.forEach((office, officeIndex) => {
console.log(`Office ${officeIndex + 1} for ${rep.name}:`, office);
if (office.lat && office.lng) {
const locationKey = `${office.lat.toFixed(6)},${office.lng.toFixed(6)}`;
if (!locationGroups.has(locationKey)) {
locationGroups.set(locationKey, {
lat: office.lat,
lng: office.lng,
address: office.address,
representatives: [],
offices: []
});
}
locationGroups.get(locationKey).representatives.push(rep);
locationGroups.get(locationKey).offices.push(office);
validOffices.push({ rep, office });
} else {
console.log(`No coordinates found for ${rep.name} office:`, office);
}
});
}
// Clear the loading message
const mapContainer = document.getElementById('main-map');
const existingMessage = mapContainer?.querySelector('.map-message');
if (existingMessage) {
existingMessage.remove();
}
// Create markers for each location group
let offsetIndex = 0;
locationGroups.forEach((locationGroup, locationKey) => {
const numReps = locationGroup.representatives.length;
console.log(`Creating markers for location ${locationKey} with ${numReps} representatives`);
if (numReps === 1) {
// Single representative at this location
const rep = locationGroup.representatives[0];
const office = locationGroup.offices[0];
const marker = createOfficeMarker(rep, office);
if (marker) {
representativeMarkers.push(marker);
marker.addTo(representativesMap);
bounds.push([office.lat, office.lng]);
}
} else {
// Multiple representatives at same location - create offset markers in a circle
locationGroup.representatives.forEach((rep, repIndex) => {
const office = locationGroup.offices[repIndex];
// Increase offset distance based on number of representatives
// More reps = larger circle for better visibility
const baseDistance = 0.001; // About 100 meters base
const offsetDistance = baseDistance * (1 + (numReps / 10)); // Scale with count
// Arrange in a circle around the point
const angle = (repIndex * 2 * Math.PI) / numReps;
const offsetLat = office.lat + (offsetDistance * Math.cos(angle));
const offsetLng = office.lng + (offsetDistance * Math.sin(angle));
const offsetOffice = {
...office,
lat: offsetLat,
lng: offsetLng,
isOffset: true,
originalLat: office.lat,
originalLng: office.lng
};
console.log(`Creating offset marker ${repIndex + 1}/${numReps} for ${rep.name} at ${offsetLat}, ${offsetLng} (offset from ${office.lat}, ${office.lng})`);
const marker = createOfficeMarker(rep, offsetOffice, true);
if (marker) {
representativeMarkers.push(marker);
marker.addTo(representativesMap);
bounds.push([offsetLat, offsetLng]);
}
});
// Add the original center point to bounds as well
bounds.push([locationGroup.lat, locationGroup.lng]);
}
});
console.log(`Total markers created: ${representativeMarkers.length}`);
console.log(`Unique locations: ${locationGroups.size}`);
console.log(`Bounds array:`, bounds);
// Log summary of locations
const locationSummary = [];
locationGroups.forEach((group, key) => {
locationSummary.push({
location: key,
address: group.address.substring(0, 50) + '...',
representatives: group.representatives.map(r => r.name).join(', ')
});
});
console.table(locationSummary);
// Fit map to show all offices, or center on Alberta if no offices found
if (bounds.length > 0) {
representativesMap.fitBounds(bounds, { padding: [20, 20] });
} else {
// If no office locations found, show a message and keep Alberta view
console.log('No office locations with coordinates found, showing message');
showMapMessage('Office locations not available for representatives in this area.');
}
console.log(`Displayed ${validOffices.length} office locations on map`);
}
// Extract office locations from representative data
async function getOfficeLocations(representative) {
const offices = [];
console.log(`Getting office locations for ${representative.name}`);
console.log('Representative offices data:', representative.offices);
// Check various sources for office location data
if (representative.offices && Array.isArray(representative.offices)) {
for (const office of representative.offices) {
console.log(`Processing office:`, office);
// Use the 'postal' field which contains the address
if (office.postal || office.address) {
const officeData = {
type: office.type || 'office',
address: office.postal || office.address || 'Office Address',
postal_code: office.postal_code,
phone: office.tel || office.phone,
fax: office.fax,
lat: office.lat,
lng: office.lng
};
console.log('Created office data:', officeData);
offices.push(officeData);
}
}
}
// For all offices without coordinates, try to geocode the address
for (const office of offices) {
if (!office.lat || !office.lng) {
console.log(`Geocoding address for ${representative.name}: ${office.address}`);
// Try geocoding the actual address first
const geocoded = await geocodeWithRateLimit(office.address);
if (geocoded) {
office.lat = geocoded.lat;
office.lng = geocoded.lng;
console.log('Geocoded office:', office);
} else {
// Fallback to city-level approximation
console.log(`Geocoding failed, using city approximation for ${representative.name}`);
const approxLocation = getApproximateLocationByDistrict(
representative.district_name,
representative.representative_set_name,
office.address
);
console.log('Approximate location:', approxLocation);
if (approxLocation) {
office.lat = approxLocation.lat;
office.lng = approxLocation.lng;
console.log('Updated office with approximate coordinates:', office);
}
}
}
}
// If no offices found at all, create a fallback office
if (offices.length === 0 && representative.representative_set_name) {
console.log(`No offices found, creating fallback office for ${representative.name}`);
// For fallback, try to get a better location based on district
const approxLocation = getApproximateLocationByDistrict(
representative.district_name,
representative.representative_set_name,
null // No address available for fallback
);
console.log('Approximate location:', approxLocation);
if (approxLocation) {
const fallbackOffice = {
type: 'representative',
address: `${representative.name} - ${representative.district_name || representative.representative_set_name}`,
lat: approxLocation.lat,
lng: approxLocation.lng
};
console.log('Created fallback office:', fallbackOffice);
offices.push(fallbackOffice);
}
}
console.log(`Total offices found for ${representative.name}:`, offices.length);
return offices;
}
// Geocoding cache to avoid repeated API calls
const geocodingCache = new Map();
// Clean and normalize address for geocoding
function normalizeAddressForGeocoding(address) {
if (!address) return '';
// Special handling for well-known government buildings
const lowerAddress = address.toLowerCase();
// Handle House of Commons / Parliament
if (lowerAddress.includes('house of commons') || lowerAddress.includes('parliament')) {
if (lowerAddress.includes('ottawa') || lowerAddress.includes('k1a')) {
return 'Parliament Hill, Ottawa, ON, Canada';
}
}
// Handle Alberta Legislature
if (lowerAddress.includes('legislature') && (lowerAddress.includes('edmonton') || lowerAddress.includes('alberta'))) {
return '10800 97 Avenue NW, Edmonton, AB, Canada';
}
// Split by newlines
const lines = address.split('\n').map(line => line.trim()).filter(line => line);
// Remove lines that are just metadata/descriptive text
const filteredLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lower = line.toLowerCase();
// Skip pure descriptive prefixes without addresses
if (lower.match(/^(main|local|district|constituency|legislature|regional)\s+(office|bureau)\s*-?\s*$/i)) {
continue; // Skip "Main office -" or "Constituency office" on their own
}
// Skip lines that are just "Main office - City" with no street address
if (lower.match(/^(main|local)\s+office\s*-\s*[a-z\s]+$/i) && !lower.match(/\d+/)) {
continue; // Skip if no street number
}
// Skip "Office:" prefixes
if (lower.match(/^office:\s*$/i)) {
continue;
}
// For lines starting with floor/suite/unit, try to extract just the street address
let cleanLine = line;
// Remove floor/suite/unit prefixes: "6th Floor, 123 Main St" -> "123 Main St"
cleanLine = cleanLine.replace(/^(suite|unit|floor|room|\d+(st|nd|rd|th)\s+floor)\s*,?\s*/i, '');
// Remove unit numbers at start: "#201, 123 Main St" -> "123 Main St"
cleanLine = cleanLine.replace(/^(#|unit|suite|ste\.?|apt\.?)\s*\d+[a-z]?\s*,\s*/i, '');
// Remove building names that precede addresses: "City Hall, 1 Main St" -> "1 Main St"
cleanLine = cleanLine.replace(/^(city hall|legislature building|federal building|provincial building),\s*/i, '');
// Clean up common building name patterns if there's a street address following
if (i === 0 && lines.length > 1) {
// If first line is just a building name and we have more lines, skip it
if (lower.match(/^(city hall|legislature|parliament|house of commons)$/i)) {
continue;
}
}
// Add the cleaned line if it has substance (contains a number for street address)
if (cleanLine.trim() && (cleanLine.match(/\d/) || cleanLine.match(/(edmonton|calgary|ottawa|alberta)/i))) {
filteredLines.push(cleanLine.trim());
}
}
// If we filtered everything, try a more lenient approach
if (filteredLines.length === 0) {
// Just join all lines and do basic cleanup
return lines
.map(line => line.replace(/^(main|local|district|constituency)\s+(office\s*-?\s*)/i, ''))
.filter(line => line.trim())
.join(', ') + ', Canada';
}
// Build cleaned address
let cleanAddress = filteredLines.join(', ');
// Fix Edmonton-style addresses: "9820 - 107 Street" -> "9820 107 Street"
cleanAddress = cleanAddress.replace(/(\d+)\s*-\s*(\d+\s+(Street|Avenue|Ave|St|Road|Rd|Drive|Dr|Boulevard|Blvd|Way|Lane|Ln))/gi, '$1 $2');
// Ensure it ends with "Canada" for better geocoding
if (!cleanAddress.toLowerCase().includes('canada')) {
cleanAddress += ', Canada';
}
return cleanAddress;
}
// Geocode an address using our backend API (which proxies to Nominatim)
async function geocodeAddress(address) {
// Check cache first
const cacheKey = address.toLowerCase().trim();
if (geocodingCache.has(cacheKey)) {
console.log(`Using cached coordinates for: ${address}`);
return geocodingCache.get(cacheKey);
}
try {
// Clean and normalize the address for better geocoding
const cleanedAddress = normalizeAddressForGeocoding(address);
console.log(`Original address: ${address}`);
console.log(`Cleaned address for geocoding: ${cleanedAddress}`);
// Call our backend geocoding endpoint
const response = await fetch('/api/geocode', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ address: cleanedAddress })
});
if (!response.ok) {
console.warn(`Geocoding API error: ${response.status}`);
return null;
}
const data = await response.json();
if (data.success && data.data && data.data.lat && data.data.lng) {
const coords = {
lat: data.data.lat,
lng: data.data.lng
};
console.log(`✓ Geocoded "${cleanedAddress}" to:`, coords);
console.log(` Display name: ${data.data.display_name}`);
// Cache the result using the original address as key
geocodingCache.set(cacheKey, coords);
return coords;
} else {
console.log(`✗ No geocoding results for: ${cleanedAddress}`);
return null;
}
} catch (error) {
console.error(`Geocoding error for "${address}":`, error);
return null;
}
}
// Rate limiter for geocoding requests (Nominatim has a 1 request/second limit)
let lastGeocodeTime = 0;
const GEOCODE_DELAY = 1100; // 1.1 seconds between requests
async function geocodeWithRateLimit(address) {
const now = Date.now();
const timeSinceLastRequest = now - lastGeocodeTime;
if (timeSinceLastRequest < GEOCODE_DELAY) {
const waitTime = GEOCODE_DELAY - timeSinceLastRequest;
console.log(`Rate limiting: waiting ${waitTime}ms before geocoding`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
lastGeocodeTime = Date.now();
return await geocodeAddress(address);
}
// Alberta city coordinates lookup table (used as fallback)
const albertaCityCoordinates = {
// Major cities
'Edmonton': { lat: 53.5461, lng: -113.4938 },
'Calgary': { lat: 51.0447, lng: -114.0719 },
'Red Deer': { lat: 52.2681, lng: -113.8111 },
'Lethbridge': { lat: 49.6942, lng: -112.8328 },
'Medicine Hat': { lat: 50.0408, lng: -110.6775 },
'Grande Prairie': { lat: 55.1708, lng: -118.7947 },
'Airdrie': { lat: 51.2917, lng: -114.0144 },
'Fort McMurray': { lat: 56.7267, lng: -111.3790 },
'Spruce Grove': { lat: 53.5450, lng: -113.9006 },
'Okotoks': { lat: 50.7251, lng: -113.9778 },
'Leduc': { lat: 53.2594, lng: -113.5517 },
'Lloydminster': { lat: 53.2782, lng: -110.0053 },
'Camrose': { lat: 53.0167, lng: -112.8233 },
'Brooks': { lat: 50.5644, lng: -111.8986 },
'Cold Lake': { lat: 54.4639, lng: -110.1825 },
'Wetaskiwin': { lat: 52.9692, lng: -113.3769 },
'Stony Plain': { lat: 53.5267, lng: -114.0069 },
'Sherwood Park': { lat: 53.5344, lng: -113.3169 },
'St. Albert': { lat: 53.6303, lng: -113.6258 },
'Beaumont': { lat: 53.3572, lng: -113.4147 },
'Cochrane': { lat: 51.1942, lng: -114.4686 },
'Canmore': { lat: 51.0886, lng: -115.3581 },
'Banff': { lat: 51.1784, lng: -115.5708 },
'Jasper': { lat: 52.8737, lng: -118.0814 },
'Hinton': { lat: 53.4053, lng: -117.5856 },
'Whitecourt': { lat: 54.1433, lng: -115.6856 },
'Slave Lake': { lat: 55.2828, lng: -114.7728 },
'High River': { lat: 50.5792, lng: -113.8744 },
'Strathmore': { lat: 51.0364, lng: -113.4006 },
'Chestermere': { lat: 51.0506, lng: -113.8228 },
'Fort Saskatchewan': { lat: 53.7103, lng: -113.2192 },
'Lacombe': { lat: 52.4678, lng: -113.7372 },
'Sylvan Lake': { lat: 52.3081, lng: -114.0958 },
'Taber': { lat: 49.7850, lng: -112.1508 },
'Drayton Valley': { lat: 53.2233, lng: -114.9819 },
'Westlock': { lat: 54.1508, lng: -113.8631 },
'Ponoka': { lat: 52.6772, lng: -113.5836 },
'Morinville': { lat: 53.8022, lng: -113.6497 },
'Vermilion': { lat: 53.3553, lng: -110.8583 },
'Drumheller': { lat: 51.4633, lng: -112.7086 },
'Peace River': { lat: 56.2364, lng: -117.2892 },
'High Prairie': { lat: 55.4358, lng: -116.4856 },
'Athabasca': { lat: 54.7192, lng: -113.2856 },
'Bonnyville': { lat: 54.2681, lng: -110.7431 },
'Vegreville': { lat: 53.4944, lng: -112.0494 },
'Innisfail': { lat: 52.0358, lng: -113.9503 },
'Provost': { lat: 52.3547, lng: -110.2681 },
'Olds': { lat: 51.7928, lng: -114.1064 },
'Pincher Creek': { lat: 49.4858, lng: -113.9506 },
'Cardston': { lat: 49.1983, lng: -113.3028 },
'Crowsnest Pass': { lat: 49.6372, lng: -114.4831 },
// Capital references
'Ottawa': { lat: 45.4215, lng: -75.6972 }, // For federal legislature offices
'AB': { lat: 53.9333, lng: -116.5765 } // Alberta center
};
// Parse city from office address
function parseCityFromAddress(addressString) {
if (!addressString) return null;
// Common patterns in addresses
const lines = addressString.split('\n').map(line => line.trim()).filter(line => line);
// Check each line for city names
for (const line of lines) {
// Look for city names in our lookup table
for (const city in albertaCityCoordinates) {
if (line.includes(city)) {
console.log(`Found city "${city}" in address line: "${line}"`);
return city;
}
}
// Check for "City, Province" pattern
const cityProvinceMatch = line.match(/^([^,]+),\s*(AB|Alberta)/i);
if (cityProvinceMatch) {
const cityName = cityProvinceMatch[1].trim();
console.log(`Extracted city from province pattern: "${cityName}"`);
// Try to find this in our lookup
for (const city in albertaCityCoordinates) {
if (cityName.toLowerCase().includes(city.toLowerCase()) ||
city.toLowerCase().includes(cityName.toLowerCase())) {
return city;
}
}
}
}
return null;
}
// Get approximate location based on office address, district, and government level
function getApproximateLocationByDistrict(district, level, officeAddress = null) {
console.log(`Getting approximate location for district: ${district}, level: ${level}, address: ${officeAddress}`);
// First, try to parse city from office address
if (officeAddress) {
const city = parseCityFromAddress(officeAddress);
if (city && albertaCityCoordinates[city]) {
console.log(`Using coordinates for city: ${city}`);
return albertaCityCoordinates[city];
}
}
// Try to extract city from district name
if (district) {
// Check if district contains a city name
for (const city in albertaCityCoordinates) {
if (district.includes(city)) {
console.log(`Found city "${city}" in district name: "${district}"`);
return albertaCityCoordinates[city];
}
}
}
// Fallback based on government level and typical office locations
const levelLocations = {
'House of Commons': albertaCityCoordinates['Ottawa'], // Federal = Ottawa
'Legislative Assembly of Alberta': { lat: 53.5344, lng: -113.5065 }, // Provincial = Legislature
'Edmonton City Council': { lat: 53.5444, lng: -113.4909 } // Municipal = City Hall
};
if (level && levelLocations[level]) {
console.log(`Using level-based location for: ${level}`);
return levelLocations[level];
}
// Last resort: Alberta center
console.log('Using default Alberta center location');
return albertaCityCoordinates['AB'];
}
// Create a marker for an office location
function createOfficeMarker(representative, office, isSharedLocation = false) {
if (!office.lat || !office.lng) {
return null;
}
// Determine icon based on government level
let icon = officeIcons.municipal; // default
if (representative.representative_set_name) {
if (representative.representative_set_name.includes('House of Commons')) {
icon = officeIcons.federal;
} else if (representative.representative_set_name.includes('Legislative Assembly')) {
icon = officeIcons.provincial;
}
}
const marker = L.marker([office.lat, office.lng], { icon });
// Create popup content
const popupContent = createOfficePopupContent(representative, office, isSharedLocation);
marker.bindPopup(popupContent, {
maxWidth: 300,
className: 'office-popup'
});
return marker;
}
// Create popup content for office markers
function createOfficePopupContent(representative, office, isSharedLocation = false) {
const level = getRepresentativeLevel(representative.representative_set_name);
const levelClass = level.toLowerCase().replace(' ', '-');
// Show note if this is an offset marker at a shared location
const locationNote = isSharedLocation
? '<p class="shared-location-note"><small><em>📍 Shared office location with other representatives</em></small></p>'
: '';
// If office has original coordinates, show actual address
const addressDisplay = office.isOffset
? `<p><strong>Address:</strong> ${office.address}</p><p><small><em>Note: Marker positioned nearby for visibility</em></small></p>`
: office.address ? `<p><strong>Address:</strong> ${office.address}</p>` : '';
return `
<div class="office-popup-content">
<div class="rep-header ${levelClass}">
${representative.photo_url ? `<img src="${representative.photo_url}" alt="${representative.name}" class="rep-photo-small">` : ''}
<div class="rep-info">
<h4>${representative.name}</h4>
<p class="rep-level">${level}</p>
<p class="rep-district">${representative.district_name || 'District not specified'}</p>
${locationNote}
</div>
</div>
<div class="office-details">
<h5>Office Information</h5>
${addressDisplay}
${office.phone ? `<p><strong>Phone:</strong> <a href="tel:${office.phone}">${office.phone}</a></p>` : ''}
${office.fax ? `<p><strong>Fax:</strong> ${office.fax}</p>` : ''}
${office.postal_code ? `<p><strong>Postal Code:</strong> ${office.postal_code}</p>` : ''}
</div>
<div class="office-actions">
${representative.email ? `<button class="btn btn-primary btn-small email-btn" data-email="${representative.email}" data-name="${representative.name}" data-level="${representative.representative_set_name}">Send Email</button>` : ''}
</div>
</div>
`;
}// Get representative level for display
function getRepresentativeLevel(representativeSetName) {
if (!representativeSetName) return 'Representative';
if (representativeSetName.includes('House of Commons')) {
return 'Federal MP';
} else if (representativeSetName.includes('Legislative Assembly')) {
return 'Provincial MLA';
} else {
return 'Municipal Representative';
}
}
// Show a message on the map
function showMapMessage(message) {
const mapContainer = document.getElementById('main-map');
if (!mapContainer) return;
// Remove any existing message
const existingMessage = mapContainer.querySelector('.map-message');
if (existingMessage) {
existingMessage.remove();
}
// Create and show new message
const messageDiv = document.createElement('div');
messageDiv.className = 'map-message';
messageDiv.innerHTML = `
<div class="map-message-content">
<p>${message}</p>
</div>
`;
mapContainer.appendChild(messageDiv);
// Remove message after 5 seconds
setTimeout(() => {
if (messageDiv.parentNode) {
messageDiv.remove();
}
}, 5000);
}
// Initialize form handlers
function initializePostalForm() {
const postalForm = document.getElementById('postal-form');
// Handle postal code form submission
if (postalForm) {
postalForm.addEventListener('submit', (event) => {
event.preventDefault();
const postalCode = document.getElementById('postal-code').value.trim();
if (postalCode) {
handlePostalCodeSubmission(postalCode);
}
});
}
// Handle email button clicks in popups
document.addEventListener('click', (event) => {
if (event.target.classList.contains('email-btn')) {
event.preventDefault();
const email = event.target.dataset.email;
const name = event.target.dataset.name;
const level = event.target.dataset.level;
if (window.openEmailModal) {
window.openEmailModal(email, name, level);
}
}
});
}
// Handle postal code submission and fetch representatives
async function handlePostalCodeSubmission(postalCode) {
try {
showLoading();
hideError();
// Normalize postal code
const normalizedPostalCode = postalCode.toUpperCase().replace(/\s/g, '');
// Fetch representatives data
const response = await fetch(`/api/representatives/by-postal/${normalizedPostalCode}`);
const data = await response.json();
if (data.success && data.data && data.data.representatives) {
// Display representatives on map (now async for geocoding)
await displayRepresentativeOffices(data.data.representatives, normalizedPostalCode);
hideLoading();
// Also update the representatives display section using the existing system
if (window.representativesDisplay) {
window.representativesDisplay.displayRepresentatives(data.data.representatives);
}
// Update location info manually if the existing system doesn't work
const locationDetails = document.getElementById('location-details');
if (locationDetails && data.data.location) {
const location = data.data.location;
locationDetails.textContent = `${location.city}, ${location.province} (${normalizedPostalCode})`;
} else if (locationDetails) {
locationDetails.textContent = `Postal Code: ${normalizedPostalCode}`;
}
if (window.locationInfo) {
window.locationInfo.updateLocationInfo(data.data.location, normalizedPostalCode);
}
// Show the representatives section
const representativesSection = document.getElementById('representatives-section');
representativesSection.style.display = 'block';
// Fix map rendering after section becomes visible
setTimeout(() => {
if (!representativesMap) {
initializeRepresentativesMap();
}
if (representativesMap) {
representativesMap.invalidateSize();
// Try to fit bounds again if we have markers
if (representativeMarkers.length > 0) {
const bounds = representativeMarkers.map(marker => marker.getLatLng());
if (bounds.length > 0) {
representativesMap.fitBounds(bounds, { padding: [20, 20] });
}
}
}
}, 300);
// Show refresh button
const refreshBtn = document.getElementById('refresh-btn');
if (refreshBtn) {
refreshBtn.style.display = 'inline-block';
// Store postal code for refresh functionality
refreshBtn.dataset.postalCode = normalizedPostalCode;
}
// Show success message
if (window.messageDisplay) {
window.messageDisplay.show(`Found ${data.data.representatives.length} representatives for ${normalizedPostalCode}`, 'success', 3000);
}
} else {
hideLoading();
showError(data.message || 'Unable to find representatives for this postal code.');
}
} catch (error) {
hideLoading();
console.error('Error fetching representatives:', error);
showError('An error occurred while looking up representatives. Please try again.');
}
}
// Utility functions for loading and error states
function showLoading() {
const loading = document.getElementById('loading');
if (loading) loading.style.display = 'block';
}
function hideLoading() {
const loading = document.getElementById('loading');
if (loading) loading.style.display = 'none';
}
function showError(message) {
const errorDiv = document.getElementById('error-message');
if (errorDiv) {
errorDiv.textContent = message;
errorDiv.style.display = 'block';
}
}
function hideError() {
const errorDiv = document.getElementById('error-message');
if (errorDiv) {
errorDiv.style.display = 'none';
}
}
// Initialize form handlers
function initializePostalForm() {
const postalForm = document.getElementById('postal-form');
const refreshBtn = document.getElementById('refresh-btn');
// Handle postal code form submission
if (postalForm) {
postalForm.addEventListener('submit', (event) => {
event.preventDefault();
const postalCode = document.getElementById('postal-code').value.trim();
if (postalCode) {
handlePostalCodeSubmission(postalCode);
}
});
}
// Handle refresh button
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
const postalCode = refreshBtn.dataset.postalCode || document.getElementById('postal-code').value.trim();
if (postalCode) {
handlePostalCodeSubmission(postalCode);
}
});
}
}
// Initialize everything when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
initializeRepresentativesMap();
initializePostalForm();
});
// Global function for opening email modal from map popups
window.openEmailModal = function(email, name, level) {
if (window.emailComposer) {
window.emailComposer.openModal({
email: email,
name: name,
level: level
}, currentPostalCode);
}
};
// Export functions for use by other modules
window.RepresentativesMap = {
displayRepresentativeOffices,
initializeRepresentativesMap,
clearRepresentativeMarkers,
handlePostalCodeSubmission
};

View File

@ -0,0 +1,977 @@
// Response Wall JavaScript
let currentCampaignSlug = null;
let currentCampaign = null;
let currentOffset = 0;
let currentSort = 'recent';
let currentLevel = '';
const LIMIT = 20;
let loadedRepresentatives = [];
// Initialize
document.addEventListener('DOMContentLoaded', () => {
console.log('Response Wall: Initializing...');
// Get campaign slug from URL if present
const urlParams = new URLSearchParams(window.location.search);
currentCampaignSlug = urlParams.get('campaign');
console.log('Campaign slug:', currentCampaignSlug);
if (!currentCampaignSlug) {
showError('No campaign specified');
return;
}
// Load initial data
loadResponseStats();
loadResponses(true);
// Set up event listeners
document.getElementById('sort-select').addEventListener('change', (e) => {
currentSort = e.target.value;
loadResponses(true);
});
document.getElementById('level-filter').addEventListener('change', (e) => {
currentLevel = e.target.value;
loadResponses(true);
});
const submitBtn = document.getElementById('submit-response-btn');
console.log('Submit button found:', !!submitBtn);
if (submitBtn) {
submitBtn.addEventListener('click', () => {
console.log('Submit button clicked');
openSubmitModal();
});
}
// Use event delegation for empty state button since it's dynamically shown
document.addEventListener('click', (e) => {
if (e.target.id === 'empty-state-submit-btn') {
console.log('Empty state button clicked');
openSubmitModal();
}
});
const modalCloseBtn = document.getElementById('modal-close-btn');
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', closeSubmitModal);
}
const cancelBtn = document.getElementById('cancel-submit-btn');
if (cancelBtn) {
cancelBtn.addEventListener('click', closeSubmitModal);
}
const loadMoreBtn = document.getElementById('load-more-btn');
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', loadMoreResponses);
}
const form = document.getElementById('submit-response-form');
if (form) {
form.addEventListener('submit', handleSubmitResponse);
}
// Postal code lookup button
const lookupBtn = document.getElementById('lookup-rep-btn');
if (lookupBtn) {
lookupBtn.addEventListener('click', handlePostalLookup);
}
// Postal code input formatting
const postalInput = document.getElementById('modal-postal-code');
if (postalInput) {
postalInput.addEventListener('input', formatPostalCodeInput);
postalInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handlePostalLookup();
}
});
}
// Representative selection
const repSelect = document.getElementById('rep-select');
if (repSelect) {
repSelect.addEventListener('change', handleRepresentativeSelect);
}
console.log('Response Wall: Initialization complete');
});
// Postal Code Lookup Functions
function formatPostalCodeInput(e) {
let value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
// Format as A1A 1A1
if (value.length > 3) {
value = value.slice(0, 3) + ' ' + value.slice(3, 6);
}
e.target.value = value;
}
function validatePostalCode(postalCode) {
const cleaned = postalCode.replace(/\s/g, '');
// Check format: Letter-Number-Letter Number-Letter-Number
const regex = /^[A-Z]\d[A-Z]\d[A-Z]\d$/;
if (!regex.test(cleaned)) {
return { valid: false, message: 'Please enter a valid postal code format (A1A 1A1)' };
}
// Check if it's an Alberta postal code (starts with T)
if (!cleaned.startsWith('T')) {
return { valid: false, message: 'This tool is designed for Alberta postal codes only (starting with T)' };
}
return { valid: true };
}
async function handlePostalLookup() {
const postalInput = document.getElementById('modal-postal-code');
const postalCode = postalInput.value.trim();
if (!postalCode) {
showError('Please enter a postal code');
return;
}
const validation = validatePostalCode(postalCode);
if (!validation.valid) {
showError(validation.message);
return;
}
const lookupBtn = document.getElementById('lookup-rep-btn');
lookupBtn.disabled = true;
lookupBtn.textContent = '🔄 Searching...';
try {
const response = await window.apiClient.getRepresentativesByPostalCode(postalCode);
const data = response.data || response;
loadedRepresentatives = data.representatives || [];
if (loadedRepresentatives.length === 0) {
showError('No representatives found for this postal code');
document.getElementById('rep-select-group').style.display = 'none';
} else {
displayRepresentativeOptions(loadedRepresentatives);
showSuccess(`Found ${loadedRepresentatives.length} representatives`);
}
} catch (error) {
console.error('Postal lookup failed:', error);
showError('Failed to lookup representatives: ' + error.message);
} finally {
lookupBtn.disabled = false;
lookupBtn.textContent = '🔍 Search';
}
}
function displayRepresentativeOptions(representatives) {
const repSelect = document.getElementById('rep-select');
const repSelectGroup = document.getElementById('rep-select-group');
// Clear existing options
repSelect.innerHTML = '';
// Add representatives as options
representatives.forEach((rep, index) => {
const option = document.createElement('option');
option.value = index;
// Format display text
let displayText = rep.name;
if (rep.district_name) {
displayText += ` - ${rep.district_name}`;
}
if (rep.party_name) {
displayText += ` (${rep.party_name})`;
}
displayText += ` [${rep.elected_office || 'Representative'}]`;
option.textContent = displayText;
repSelect.appendChild(option);
});
// Show the select group
repSelectGroup.style.display = 'block';
}
function handleRepresentativeSelect(e) {
const selectedIndex = e.target.value;
if (selectedIndex === '') return;
const rep = loadedRepresentatives[selectedIndex];
if (!rep) return;
// Auto-fill form fields
document.getElementById('representative-name').value = rep.name || '';
document.getElementById('representative-title').value = rep.elected_office || '';
// Set government level based on elected office
const level = determineGovernmentLevel(rep.elected_office);
document.getElementById('representative-level').value = level;
// Store email for verification option
if (rep.email) {
// Handle email being either string or array
const emailValue = Array.isArray(rep.email) ? rep.email[0] : rep.email;
document.getElementById('representative-email').value = emailValue;
// Enable verification checkbox if we have an email
const verificationCheckbox = document.getElementById('send-verification');
verificationCheckbox.disabled = false;
} else {
document.getElementById('representative-email').value = '';
// Disable verification checkbox if no email
const verificationCheckbox = document.getElementById('send-verification');
verificationCheckbox.disabled = true;
verificationCheckbox.checked = false;
}
showSuccess('Representative details filled. Please complete the rest of the form.');
}
function determineGovernmentLevel(electedOffice) {
if (!electedOffice) return '';
const office = electedOffice.toLowerCase();
if (office.includes('mp') || office.includes('member of parliament')) {
return 'Federal';
} else if (office.includes('mla') || office.includes('member of the legislative assembly')) {
return 'Provincial';
} else if (office.includes('councillor') || office.includes('councilor') || office.includes('mayor')) {
return 'Municipal';
} else if (office.includes('trustee') || office.includes('school board')) {
return 'School Board';
}
return '';
}
// Load response statistics and campaign details
async function loadResponseStats() {
try {
const data = await window.apiClient.get(`/campaigns/${currentCampaignSlug}/response-stats`);
if (data.success) {
// Store campaign data
currentCampaign = data.campaign;
// Update stats
document.getElementById('stat-total-responses').textContent = data.stats.totalResponses;
document.getElementById('stat-verified').textContent = data.stats.verifiedResponses;
document.getElementById('stat-upvotes').textContent = data.stats.totalUpvotes;
document.getElementById('stats-banner').style.display = 'flex';
// Render campaign header with campaign info
renderCampaignHeader();
}
} catch (error) {
console.error('Error loading stats:', error);
}
}
// Render campaign header with background image and navigation
function renderCampaignHeader() {
console.log('renderCampaignHeader called with campaign:', currentCampaign);
if (!currentCampaign) {
console.warn('No campaign data available');
return;
}
const headerElement = document.querySelector('.response-wall-header');
if (!headerElement) {
console.warn('Header element not found');
return;
}
// Update campaign name subtitle
const campaignNameElement = document.getElementById('campaign-name');
const descriptionElement = document.getElementById('campaign-description');
console.log('Campaign name element:', campaignNameElement);
console.log('Campaign title:', currentCampaign.title);
if (campaignNameElement && currentCampaign.title) {
campaignNameElement.textContent = currentCampaign.title;
campaignNameElement.style.display = 'block';
console.log('Campaign name set to:', currentCampaign.title);
}
if (descriptionElement) {
descriptionElement.textContent = currentCampaign.description || 'See what representatives are saying back to constituents';
}
// Add cover photo if available
if (currentCampaign.cover_photo) {
headerElement.classList.add('has-cover');
headerElement.style.backgroundImage = `url(/uploads/${currentCampaign.cover_photo})`;
} else {
headerElement.classList.remove('has-cover');
headerElement.style.backgroundImage = '';
}
// Set up navigation button listeners
const campaignBtn = document.getElementById('nav-to-campaign');
const homeBtn = document.getElementById('nav-to-home');
if (campaignBtn) {
campaignBtn.addEventListener('click', () => {
window.location.href = `/campaign/${currentCampaign.slug}`;
});
}
if (homeBtn) {
homeBtn.addEventListener('click', () => {
window.location.href = '/';
});
}
// Set up social share buttons
setupShareButtons();
}
// Setup social share buttons
function setupShareButtons() {
const shareUrl = window.location.href;
// Social menu toggle
const socialsToggle = document.getElementById('share-socials-toggle');
const socialsMenu = document.getElementById('share-socials-menu');
if (socialsToggle && socialsMenu) {
socialsToggle.addEventListener('click', (e) => {
e.stopPropagation();
socialsMenu.classList.toggle('show');
socialsToggle.classList.toggle('active');
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.share-socials-container')) {
socialsMenu.classList.remove('show');
socialsToggle.classList.remove('active');
}
});
}
// Facebook share
document.getElementById('share-facebook')?.addEventListener('click', () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Twitter share
document.getElementById('share-twitter')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// LinkedIn share
document.getElementById('share-linkedin')?.addEventListener('click', () => {
const url = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// WhatsApp share
document.getElementById('share-whatsapp')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://wa.me/?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank');
});
// Bluesky share
document.getElementById('share-bluesky')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Instagram share (note: Instagram doesn't have direct web share, opens Instagram web)
document.getElementById('share-instagram')?.addEventListener('click', () => {
alert('To share on Instagram:\n1. Copy the link (use the copy button)\n2. Open Instagram app\n3. Create a post or story\n4. Paste the link in your caption');
// Automatically copy the link
navigator.clipboard.writeText(shareUrl).catch(() => {
console.log('Failed to copy link automatically');
});
});
// Reddit share
document.getElementById('share-reddit')?.addEventListener('click', () => {
const title = currentCampaign ? `Representative Responses: ${currentCampaign.title}` : 'Check out these representative responses';
const url = `https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(title)}`;
window.open(url, '_blank', 'width=800,height=600');
});
// Threads share
document.getElementById('share-threads')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://threads.net/intent/post?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Telegram share
document.getElementById('share-telegram')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Mastodon share
document.getElementById('share-mastodon')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
// Mastodon requires instance selection - opens a composer with text
const instance = prompt('Enter your Mastodon instance (e.g., mastodon.social):');
if (instance) {
const url = `https://${instance}/share?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
}
});
// SMS share
document.getElementById('share-sms')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const body = text + ' ' + shareUrl;
// Use Web Share API if available, otherwise fallback to SMS protocol
if (navigator.share) {
navigator.share({
title: currentCampaign ? currentCampaign.title : 'Response Wall',
text: body
}).catch(() => {
// Fallback to SMS protocol
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
});
} else {
// SMS protocol (works on mobile)
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
}
});
// Slack share
document.getElementById('share-slack')?.addEventListener('click', () => {
const url = `https://slack.com/intl/en-ca/share?url=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Discord share
document.getElementById('share-discord')?.addEventListener('click', () => {
alert('To share on Discord:\n1. Copy the link (use the copy button)\n2. Open Discord\n3. Paste the link in any channel or DM\n\nDiscord will automatically create a preview!');
// Automatically copy the link
navigator.clipboard.writeText(shareUrl).catch(() => {
console.log('Failed to copy link automatically');
});
});
// Print/PDF share
document.getElementById('share-print')?.addEventListener('click', () => {
window.print();
});
// Email share
document.getElementById('share-email')?.addEventListener('click', () => {
const subject = currentCampaign ? `Representative Responses: ${currentCampaign.title}` : 'Check out these representative responses';
const body = currentCampaign ?
`I thought you might be interested in seeing what representatives are saying about this campaign:\n\n${currentCampaign.title}\n\n${shareUrl}` :
`Check out these representative responses:\n\n${shareUrl}`;
window.location.href = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
});
// Copy link
document.getElementById('share-copy')?.addEventListener('click', async () => {
const copyBtn = document.getElementById('share-copy');
try {
await navigator.clipboard.writeText(shareUrl);
copyBtn.classList.add('copied');
copyBtn.title = 'Copied!';
setTimeout(() => {
copyBtn.classList.remove('copied');
copyBtn.title = 'Copy Link';
}, 2000);
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = shareUrl;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
copyBtn.classList.add('copied');
copyBtn.title = 'Copied!';
setTimeout(() => {
copyBtn.classList.remove('copied');
copyBtn.title = 'Copy Link';
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
alert('Failed to copy link. Please copy manually: ' + shareUrl);
}
document.body.removeChild(textArea);
}
});
// QR code share
document.getElementById('share-qrcode')?.addEventListener('click', () => {
openQRCodeModal();
});
}
function openQRCodeModal() {
const modal = document.getElementById('qrcode-modal');
const qrcodeImage = document.getElementById('qrcode-image');
const closeBtn = modal.querySelector('.qrcode-close');
const downloadBtn = document.getElementById('download-qrcode-btn');
// Build QR code URL for response wall
const qrcodeUrl = `/api/campaigns/${currentCampaignSlug}/qrcode?type=response-wall`;
qrcodeImage.src = qrcodeUrl;
// Show modal
modal.classList.add('show');
// Close button handler
const closeModal = () => {
modal.classList.remove('show');
};
closeBtn.onclick = closeModal;
// Close when clicking outside the modal content
modal.onclick = (event) => {
if (event.target === modal) {
closeModal();
}
};
// Download button handler
downloadBtn.onclick = async () => {
try {
const response = await fetch(qrcodeUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${currentCampaignSlug}-response-wall-qrcode.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download QR code:', error);
alert('Failed to download QR code. Please try again.');
}
};
// Close on Escape key
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
}
// Load responses
async function loadResponses(reset = false) {
if (reset) {
currentOffset = 0;
document.getElementById('responses-container').innerHTML = '';
}
showLoading(true);
try {
const params = new URLSearchParams({
sort: currentSort,
level: currentLevel,
offset: currentOffset,
limit: LIMIT
});
const data = await window.apiClient.get(`/campaigns/${currentCampaignSlug}/responses?${params}`);
showLoading(false);
if (data.success && data.responses.length > 0) {
renderResponses(data.responses);
// Show/hide load more button
if (data.pagination.hasMore) {
document.getElementById('load-more-container').style.display = 'block';
} else {
document.getElementById('load-more-container').style.display = 'none';
}
} else if (reset) {
showEmptyState();
}
} catch (error) {
showLoading(false);
showError('Failed to load responses');
console.error('Error loading responses:', error);
}
}
// Render responses
function renderResponses(responses) {
const container = document.getElementById('responses-container');
responses.forEach(response => {
const card = createResponseCard(response);
container.appendChild(card);
});
}
// Create response card element
function createResponseCard(response) {
const card = document.createElement('div');
card.className = 'response-card';
card.dataset.responseId = response.id;
const createdDate = new Date(response.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
let badges = `<span class="badge badge-level">${escapeHtml(response.representative_level)}</span>`;
badges += `<span class="badge badge-type">${escapeHtml(response.response_type)}</span>`;
if (response.is_verified) {
badges = `<span class="badge badge-verified">✓ Verified</span>` + badges;
}
let submittedBy = 'Anonymous';
if (!response.is_anonymous && response.submitted_by_name) {
submittedBy = escapeHtml(response.submitted_by_name);
}
let userCommentHtml = '';
if (response.user_comment) {
userCommentHtml = `
<div class="user-comment">
<span class="user-comment-label">Constituent's Comment:</span>
${escapeHtml(response.user_comment)}
</div>
`;
}
let screenshotHtml = '';
if (response.screenshot_url) {
screenshotHtml = `
<div class="response-screenshot">
<img src="${escapeHtml(response.screenshot_url)}"
alt="Response screenshot"
data-image-url="${escapeHtml(response.screenshot_url)}"
class="screenshot-image">
</div>
`;
}
const upvoteClass = response.hasUpvoted ? 'upvoted' : '';
// Add verify button HTML if response is unverified and has representative email
let verifyButtonHtml = '';
if (!response.is_verified && response.representative_email) {
// Show button if we have a representative email, regardless of whether verification was initially requested
verifyButtonHtml = `
<button class="verify-btn" data-response-id="${response.id}" data-verification-token="${escapeHtml(response.verification_token || '')}" data-rep-email="${escapeHtml(response.representative_email)}">
<span class="verify-icon">📧</span>
<span class="verify-text">Send Verification Email</span>
</button>
`;
}
card.innerHTML = `
<div class="response-header">
<div class="response-rep-info">
<h3>${escapeHtml(response.representative_name)}</h3>
<div class="rep-meta">
${response.representative_title ? `<span>${escapeHtml(response.representative_title)}</span>` : ''}
<span>${createdDate}</span>
</div>
</div>
<div class="response-badges">
${badges}
</div>
</div>
<div class="response-content">
<div class="response-text">${escapeHtml(response.response_text)}</div>
${userCommentHtml}
${screenshotHtml}
</div>
<div class="response-footer">
<div class="response-meta">
Submitted by ${submittedBy}
</div>
<div class="response-actions">
<button class="upvote-btn ${upvoteClass}" data-response-id="${response.id}">
<span class="upvote-icon">👍</span>
<span class="upvote-count">${response.upvote_count || 0}</span>
</button>
${verifyButtonHtml}
</div>
</div>
`;
// Add event listener for upvote button
const upvoteBtn = card.querySelector('.upvote-btn');
upvoteBtn.addEventListener('click', function() {
toggleUpvote(response.id, this);
});
// Add event listener for verify button if present
const verifyBtn = card.querySelector('.verify-btn');
if (verifyBtn) {
verifyBtn.addEventListener('click', function() {
handleVerifyClick(response.id, this.dataset.verificationToken, this.dataset.repEmail);
});
}
// Add event listener for screenshot image if present
const screenshotImg = card.querySelector('.screenshot-image');
if (screenshotImg) {
screenshotImg.addEventListener('click', function() {
viewImage(this.dataset.imageUrl);
});
}
return card;
}
// Toggle upvote
async function toggleUpvote(responseId, button) {
const isUpvoted = button.classList.contains('upvoted');
const url = `/responses/${responseId}/upvote`;
try {
const data = isUpvoted
? await window.apiClient.delete(url)
: await window.apiClient.post(url, {});
if (data.success) {
// Update button state
button.classList.toggle('upvoted');
button.querySelector('.upvote-count').textContent = data.upvoteCount;
// Reload stats
loadResponseStats();
} else {
showError(data.error || 'Failed to update upvote');
}
} catch (error) {
console.error('Error toggling upvote:', error);
showError('Failed to update upvote');
}
}
// Load more responses
function loadMoreResponses() {
currentOffset += LIMIT;
loadResponses(false);
}
// Open submit modal
function openSubmitModal() {
console.log('openSubmitModal called');
const modal = document.getElementById('submit-modal');
if (modal) {
modal.style.display = 'block';
console.log('Modal displayed');
} else {
console.error('Modal element not found');
}
}
// Close submit modal
function closeSubmitModal() {
document.getElementById('submit-modal').style.display = 'none';
document.getElementById('submit-response-form').reset();
// Reset postal code lookup
document.getElementById('rep-select-group').style.display = 'none';
document.getElementById('rep-select').innerHTML = '';
loadedRepresentatives = [];
// Reset hidden fields
document.getElementById('representative-email').value = '';
// Reset verification checkbox
const verificationCheckbox = document.getElementById('send-verification');
verificationCheckbox.disabled = false;
verificationCheckbox.checked = false;
}
// Handle response submission
async function handleSubmitResponse(e) {
e.preventDefault();
const formData = new FormData(e.target);
// Note: Both send_verification checkbox and representative_email hidden field
// are already included in FormData from the form
// send_verification will be 'on' if checked, undefined if not checked
// representative_email will be populated by handleRepresentativeSelect()
// Get verification status for UI feedback
const sendVerification = document.getElementById('send-verification').checked;
const repEmail = document.getElementById('representative-email').value;
try {
const data = await window.apiClient.postFormData(`/campaigns/${currentCampaignSlug}/responses`, formData);
if (data.success) {
let message = data.message || 'Response submitted successfully! It will appear after moderation.';
if (sendVerification && repEmail) {
message += ' A verification email has been sent to the representative.';
}
showSuccess(message);
closeSubmitModal();
// Don't reload responses since submission is pending approval
} else {
showError(data.error || 'Failed to submit response');
}
} catch (error) {
console.error('Error submitting response:', error);
showError('Failed to submit response');
}
}
// Handle verify button click
async function handleVerifyClick(responseId, verificationToken, representativeEmail) {
// Mask email to show only first 3 characters and domain
// e.g., "john.doe@example.com" becomes "joh***@example.com"
const maskEmail = (email) => {
const [localPart, domain] = email.split('@');
if (localPart.length <= 3) {
return `${localPart}***@${domain}`;
}
return `${localPart.substring(0, 3)}***@${domain}`;
};
const maskedEmail = maskEmail(representativeEmail);
// Step 1: Prompt the representative to verify their identity by entering their email
const emailPrompt = prompt(
'To send a verification email, please enter the representative\'s email address.\n\n' +
'This email must match the representative email on file for this response.\n\n' +
`Email on file: ${maskedEmail}`,
''
);
// User cancelled
if (emailPrompt === null) {
return;
}
// Trim and lowercase for comparison
const enteredEmail = emailPrompt.trim().toLowerCase();
const storedEmail = representativeEmail.trim().toLowerCase();
// Check if email is empty
if (!enteredEmail) {
showError('Email address is required to send verification.');
return;
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(enteredEmail)) {
showError('Please enter a valid email address.');
return;
}
// Check if email matches
if (enteredEmail !== storedEmail) {
showError(
'The email you entered does not match the representative email on file.\n\n' +
`Expected: ${representativeEmail}\n` +
`You entered: ${emailPrompt.trim()}\n\n` +
'Verification email cannot be sent.'
);
return;
}
// Step 2: Email matches - confirm sending verification email
const confirmSend = confirm(
'Email verified! Ready to send verification email.\n\n' +
`A verification email will be sent to: ${representativeEmail}\n\n` +
'The representative will receive an email with a link to verify this response as authentic.\n\n' +
'Do you want to send the verification email?'
);
if (!confirmSend) {
return;
}
// Make request to resend verification email
try {
const data = await window.apiClient.post(`/responses/${responseId}/resend-verification`, {});
if (data.success) {
showSuccess(
'Verification email sent successfully!\n\n' +
`An email has been sent to ${representativeEmail} with a verification link.\n\n` +
'The representative must click the link in the email to complete verification.'
);
} else {
showError(data.error || 'Failed to send verification email. Please try again.');
}
} catch (error) {
console.error('Error sending verification email:', error);
showError('An error occurred while sending the verification email. Please try again.');
}
}
// View image in modal/new tab
function viewImage(url) {
window.open(url, '_blank');
}
// Utility functions
function showLoading(show) {
document.getElementById('loading').style.display = show ? 'block' : 'none';
}
function showEmptyState() {
document.getElementById('empty-state').style.display = 'block';
document.getElementById('responses-container').innerHTML = '';
document.getElementById('load-more-container').style.display = 'none';
}
function showError(message) {
// Simple alert for now - could integrate with existing error display system
alert('Error: ' + message);
}
function showSuccess(message) {
// Simple alert for now - could integrate with existing success display system
alert(message);
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('submit-modal');
if (event.target === modal) {
closeSubmitModal();
}
};

View File

@ -0,0 +1,89 @@
// Email Verification Handler
document.addEventListener('DOMContentLoaded', async () => {
const statusDiv = document.getElementById('verification-status');
// Get token from URL query parameters
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (!token) {
showError('Invalid verification link - no token provided');
return;
}
try {
// Call API to verify token
const response = await fetch(`/api/verify-email/${token}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include' // Important for session management
});
const data = await response.json();
if (data.success) {
// Store campaign data in session storage for dashboard
if (data.campaignData) {
sessionStorage.setItem('pendingCampaign', JSON.stringify(data.campaignData));
}
// Show success and redirect
if (data.isNewUser) {
showSuccess(
'✅ Welcome! Your account has been created successfully. Check your email for login credentials. Your email content is ready - just add a campaign title and description!',
'/dashboard.html',
'Go to Dashboard',
4000
);
} else if (data.needsAccount) {
showSuccess(
'Email verified! Please create your account to continue.',
'/login.html',
'Go to Login',
3000
);
} else {
showSuccess(
'Email verified! Redirecting to campaign creation...',
'/dashboard.html',
'Go to Dashboard',
2000
);
}
} else {
showError(data.error || 'Verification failed');
}
} catch (error) {
console.error('Verification error:', error);
showError('An error occurred during verification. Please try again or contact support.');
}
});
function showSuccess(message, redirectUrl, buttonText, autoRedirectDelay = 3000) {
const statusDiv = document.getElementById('verification-status');
statusDiv.innerHTML = `
<div class="success-icon"></div>
<h2 style="color: #28a745;">Verification Successful!</h2>
<p class="message">${message}</p>
<a href="${redirectUrl}" class="btn">${buttonText}</a>
`;
// Auto-redirect after delay
setTimeout(() => {
window.location.href = redirectUrl;
}, autoRedirectDelay);
}
function showError(errorMessage) {
const statusDiv = document.getElementById('verification-status');
statusDiv.innerHTML = `
<div class="error-icon"></div>
<h2 style="color: #dc3545;">Verification Failed</h2>
<p class="message">${errorMessage}</p>
<a href="/" class="btn" style="background-color: #666;">Return to Home</a>
`;
}

View File

@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - BNKops Influence Campaign Tool</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/styles.css">
<style>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-card {
background-color: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
padding: 40px;
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h1 {
color: #2c3e50;
font-size: 28px;
margin-bottom: 10px;
}
.login-header p {
color: #666;
font-size: 16px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #2c3e50;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #3498db;
}
.btn-login {
width: 100%;
padding: 12px;
background: #3498db;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.3s;
margin-top: 10px;
}
.btn-login:hover {
background: #2980b9;
}
.btn-login:disabled {
background: #bdc3c7;
cursor: not-allowed;
}
.error-message {
color: #e74c3c;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 8px;
padding: 12px;
margin-bottom: 20px;
font-size: 14px;
}
.loading {
display: none;
text-align: center;
margin-top: 20px;
}
.spinner {
border: 3px solid #f3f3f3;
border-radius: 50%;
border-top: 3px solid #3498db;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.back-link {
text-align: center;
margin-top: 20px;
}
.back-link a {
color: #3498db;
text-decoration: none;
font-size: 14px;
}
.back-link a:hover {
text-decoration: underline;
}
@media (max-width: 480px) {
.login-card {
padding: 30px 20px;
}
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>Login</h1>
<p>Access your campaign dashboard</p>
</div>
<div id="error-message" class="error-message" style="display: none;"></div>
<form id="login-form">
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" autocomplete="email" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" id="login-btn" class="btn-login">
<span id="login-text">Login</span>
<div class="loading">
<div class="spinner"></div>
</div>
</button>
</form>
<div class="back-link">
<a href="/" id="home-link">← Back to Campaign Tool</a>
</div>
</div>
</div>
<script src="js/api-client.js"></script>
<script src="js/login.js"></script>
<script>
// Update navigation link with APP_URL if needed
fetch('/api/config')
.then(res => res.json())
.then(config => {
if (config.appUrl && !window.location.href.startsWith(config.appUrl)) {
document.getElementById('home-link').href = config.appUrl + '/';
}
})
.catch(err => console.log('Config not loaded, using relative paths'));
</script>
</body>
</html>

View File

@ -0,0 +1,301 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Response Wall | BNKops Influence</title>
<link rel="stylesheet" href="/css/styles.css">
<link rel="stylesheet" href="/css/response-wall.css">
</head>
<body>
<!-- Campaign Header -->
<div class="response-wall-header">
<div class="response-wall-header-content">
<h1>📢 Community Response Wall</h1>
<h2 id="campaign-name" class="campaign-subtitle" style="display: none;"></h2>
<p id="campaign-description">See what representatives are saying back to constituents</p>
<div class="header-nav-buttons">
<button class="nav-btn" id="nav-to-campaign">
← Back to Campaign
</button>
<button class="nav-btn" id="nav-to-home">
🏠 Home
</button>
</div>
<!-- Social Share Buttons in Header -->
<div class="share-buttons-header">
<!-- Expandable Social Menu -->
<div class="share-socials-container">
<button class="share-btn-primary" id="share-socials-toggle" title="Share on Socials">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="share-icon">
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
</svg>
<span>Socials</span>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="chevron-icon">
<path d="M7 10l5 5 5-5z"/>
</svg>
</button>
<!-- Expandable Social Options -->
<div class="share-socials-menu" id="share-socials-menu">
<button class="share-btn-small" id="share-facebook" title="Facebook">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
</button>
<button class="share-btn-small" id="share-twitter" title="Twitter/X">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</button>
<button class="share-btn-small" id="share-linkedin" title="LinkedIn">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
</button>
<button class="share-btn-small" id="share-whatsapp" title="WhatsApp">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
</button>
<button class="share-btn-small" id="share-bluesky" title="Bluesky">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"/></svg>
</button>
<button class="share-btn-small" id="share-instagram" title="Instagram">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4H7.6m9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8 1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3z"/></svg>
</button>
<button class="share-btn-small" id="share-reddit" title="Reddit">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/></svg>
</button>
<button class="share-btn-small" id="share-threads" title="Threads">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-.542-1.947-1.499-3.488-2.846-4.576-1.488-1.2-3.457-1.806-5.854-1.826h-.01c-3.015.022-5.26.918-6.675 2.662C3.873 6.034 3.13 8.39 3.108 11.98v.014c.022 3.585.766 5.937 2.209 6.99 1.407 1.026 3.652 1.545 6.674 1.545h.01c.297 0 .597-.002.892-.009l.034 2.037c-.33.008-.665.012-1.001.012zM17.822 15.13v.002c-.184 1.376-.865 2.465-2.025 3.234-1.222.81-2.878 1.221-4.922 1.221-1.772 0-3.185-.34-4.197-1.009-.944-.625-1.488-1.527-1.617-2.68-.119-1.066.152-2.037.803-2.886.652-.85 1.595-1.464 2.802-1.823 1.102-.33 2.396-.495 3.847-.495h.343v1.615h-.343c-1.274 0-2.395.144-3.332.428-.937.284-1.653.713-2.129 1.275-.476.562-.664 1.229-.556 1.979.097.671.45 1.21 1.051 1.603.723.473 1.816.711 3.252.711 1.738 0 3.097-.35 4.042-.995.809-.552 1.348-1.349 1.603-2.373l1.98.193zM12.626 10.561v.002c-1.197 0-2.234.184-3.083.546-.938.4-1.668 1.017-2.169 1.835-.499.816-.748 1.792-.739 2.902l-2.037-.022c-.012-1.378.304-2.608.939-3.658.699-1.158 1.688-2.065 2.941-2.696 1.05-.527 2.274-.792 3.638-.792h.51v1.883h-.51z"/></svg>
</button>
<button class="share-btn-small" id="share-telegram" title="Telegram">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>
</button>
<button class="share-btn-small" id="share-mastodon" title="Mastodon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
</button>
<button class="share-btn-small" id="share-sms" title="SMS">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12zM7 9h2v2H7zm4 0h2v2h-2zm4 0h2v2h-2z"/></svg>
</button>
<button class="share-btn-small" id="share-slack" title="Slack">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>
</button>
<button class="share-btn-small" id="share-discord" title="Discord">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</button>
<button class="share-btn-small" id="share-print" title="Print">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/></svg>
</button>
<button class="share-btn-small" id="share-email" title="Email">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
</button>
</div>
</div>
<!-- Always-visible buttons -->
<button class="share-btn-primary" id="share-copy" title="Copy Link">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
<span>Copy Link</span>
</button>
<button class="share-btn-primary" id="share-qrcode" title="Show QR Code">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zM13 3v8h8V3h-8zm6 6h-4V5h4v4zM13 13h2v2h-2zM15 15h2v2h-2zM13 17h2v2h-2zM17 13h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM19 19h2v2h-2z"/>
</svg>
<span>QR Code</span>
</button>
</div>
</div>
</div>
<div class="container">
<!-- Stats Banner -->
<div class="stats-banner" id="stats-banner" style="display: none;">
<div class="stat-item">
<span class="stat-number" id="stat-total-responses">0</span>
<span class="stat-label">Total Responses</span>
</div>
<div class="stat-item">
<span class="stat-number" id="stat-verified">0</span>
<span class="stat-label">Verified</span>
</div>
<div class="stat-item">
<span class="stat-number" id="stat-upvotes">0</span>
<span class="stat-label">Total Upvotes</span>
</div>
</div>
<!-- Controls -->
<div class="response-controls">
<div class="filter-group">
<label for="sort-select">Sort by:</label>
<select id="sort-select">
<option value="recent">Most Recent</option>
<option value="upvotes">Most Upvoted</option>
<option value="verified">Verified First</option>
</select>
</div>
<div class="filter-group">
<label for="level-filter">Filter by Level:</label>
<select id="level-filter">
<option value="">All Levels</option>
<option value="Federal">Federal</option>
<option value="Provincial">Provincial</option>
<option value="Municipal">Municipal</option>
<option value="School Board">School Board</option>
</select>
</div>
<button class="btn btn-primary" id="submit-response-btn">
✍️ Share a Response
</button>
</div>
<!-- Loading Indicator -->
<div id="loading" class="loading" style="display: none;">
<p>Loading responses...</p>
</div>
<!-- Empty State -->
<div id="empty-state" class="empty-state" style="display: none;">
<p>No responses yet. Be the first to share!</p>
<button class="btn btn-primary" id="empty-state-submit-btn">Share a Response</button>
</div>
<!-- Responses Container -->
<div id="responses-container"></div>
<!-- Load More Button -->
<div class="load-more-container" id="load-more-container" style="display: none;">
<button class="btn btn-secondary" id="load-more-btn">Load More</button>
</div>
</div>
<!-- Submit Response Modal -->
<div id="submit-modal" class="modal">
<div class="modal-content">
<span class="close" id="modal-close-btn">&times;</span>
<h2>Share a Representative Response</h2>
<form id="submit-response-form" enctype="multipart/form-data">
<!-- Postal Code Lookup -->
<div class="form-group">
<label for="modal-postal-code">Find Your Representative by Postal Code</label>
<div class="postal-lookup-container">
<input type="text" id="modal-postal-code" placeholder="Enter postal code (e.g., T5K 2J1)" maxlength="7">
<button type="button" class="btn btn-secondary" id="lookup-rep-btn">🔍 Search</button>
</div>
<small>Search for representatives by postal code to auto-fill details</small>
</div>
<!-- Representatives Selection (Hidden by default) -->
<div class="form-group" id="rep-select-group" style="display: none;">
<label for="rep-select">Select Representative *</label>
<select id="rep-select" size="5">
<!-- Options will be populated by JavaScript -->
</select>
<small>Click on a representative to auto-fill the form</small>
</div>
<!-- Manual Entry Fields -->
<div class="form-group">
<label for="representative-name">Representative Name *</label>
<input type="text" id="representative-name" name="representative_name" required>
<small>Or enter manually if not found above</small>
</div>
<div class="form-group">
<label for="representative-title">Representative Title</label>
<input type="text" id="representative-title" name="representative_title" placeholder="e.g., MLA, MP, Councillor">
</div>
<div class="form-group">
<label for="representative-level">Government Level *</label>
<select id="representative-level" name="representative_level" required>
<option value="">Select level...</option>
<option value="Federal">Federal</option>
<option value="Provincial">Provincial</option>
<option value="Municipal">Municipal</option>
<option value="School Board">School Board</option>
</select>
</div>
<!-- Hidden field to store representative email for verification -->
<input type="hidden" id="representative-email" name="representative_email">
<div class="form-group">
<label for="response-type">Response Type *</label>
<select id="response-type" name="response_type" required>
<option value="">Select type...</option>
<option value="Email">Email</option>
<option value="Letter">Letter</option>
<option value="Phone Call">Phone Call</option>
<option value="Meeting">Meeting</option>
<option value="Social Media">Social Media</option>
<option value="Other">Other</option>
</select>
</div>
<div class="form-group">
<label for="response-text">Response Text *</label>
<textarea id="response-text" name="response_text" rows="6" required placeholder="Paste or type the response you received..."></textarea>
</div>
<div class="form-group">
<label for="user-comment">Your Comment (Optional)</label>
<textarea id="user-comment" name="user_comment" rows="3" placeholder="Add your thoughts about this response..."></textarea>
</div>
<div class="form-group">
<label for="screenshot">Screenshot (Optional)</label>
<input type="file" id="screenshot" name="screenshot" accept="image/*">
<small>Upload a screenshot of the response (max 5MB)</small>
</div>
<div class="form-group">
<label for="submitted-by-name">Your Name (Optional)</label>
<input type="text" id="submitted-by-name" name="submitted_by_name">
</div>
<div class="form-group">
<label for="submitted-by-email">Your Email (Optional)</label>
<input type="email" id="submitted-by-email" name="submitted_by_email">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="is-anonymous" name="is_anonymous">
Post anonymously (don't show my name)
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="send-verification" name="send_verification">
Send verification request to representative
</label>
<small>This will email the representative to verify this response is authentic</small>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancel-submit-btn">Cancel</button>
<button type="submit" class="btn btn-primary">Submit Response</button>
</div>
</form>
</div>
</div>
<!-- QR Code Modal -->
<div id="qrcode-modal" class="qrcode-modal">
<div class="qrcode-modal-content">
<span class="qrcode-close">&times;</span>
<h2>Scan QR Code to Visit Response Wall</h2>
<div class="qrcode-container">
<img id="qrcode-image" src="" alt="Response Wall QR Code">
</div>
<p class="qrcode-instructions">Scan this code with your phone to visit this response wall</p>
<button class="btn btn-secondary" id="download-qrcode-btn">Download QR Code</button>
</div>
</div>
<script src="/js/api-client.js"></script>
<script src="/js/response-wall.js"></script>
</body>
</html>

View File

@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terms of Use - BNKops Influence Campaign Tool</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/styles.css">
<style>
.terms-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
line-height: 1.6;
}
.terms-header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #e9ecef;
}
.terms-content h2 {
color: #2c3e50;
margin-top: 2rem;
margin-bottom: 1rem;
}
.terms-content h3 {
color: #34495e;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.terms-content p {
margin-bottom: 1rem;
text-align: justify;
}
.terms-content ul {
margin-bottom: 1rem;
padding-left: 2rem;
}
.terms-content li {
margin-bottom: 0.5rem;
}
.highlight-box {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
padding: 1rem;
margin: 1.5rem 0;
}
.contact-info {
background: #f8f9fa;
border-left: 4px solid #3498db;
padding: 1rem;
margin: 1.5rem 0;
}
.back-link {
display: inline-block;
margin-bottom: 1rem;
color: #3498db;
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
.last-updated {
font-style: italic;
color: #666;
text-align: center;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #e9ecef;
}
</style>
</head>
<body>
<div class="terms-container">
<a href="javascript:history.back()" class="back-link">← Back</a>
<header class="terms-header">
<h1>Terms of Use & Privacy Notice</h1>
<p>BNKops Influence Campaign Tool</p>
</header>
<div class="terms-content">
<div class="highlight-box">
<strong>Important Notice:</strong> By using this application, you acknowledge that your interactions are recorded and you may receive communications from BNKops, the operator of this website. This service is provided to facilitate democratic engagement between Canadian residents and their elected representatives.
</div>
<h2>1. Acceptance of Terms</h2>
<p>By accessing and using the BNKops Influence Campaign Tool (the "Service"), you accept and agree to be bound by the terms and provision of this agreement. This Service is operated by BNKops ("we," "us," or "our") and is intended for use by Canadian residents to facilitate communication with their elected representatives.</p>
<h2>2. Description of Service</h2>
<p>The BNKops Influence Campaign Tool is a web-based platform that enables users to:</p>
<ul>
<li>Find their elected representatives at federal, provincial, and municipal levels</li>
<li>Access contact information for these representatives</li>
<li>Compose and send emails to their representatives</li>
<li>Participate in organized advocacy campaigns</li>
<li>View office locations and contact details</li>
</ul>
<h2>3. Data Collection and Privacy</h2>
<h3>3.1 Information We Collect</h3>
<p>We collect and store the following information:</p>
<ul>
<li><strong>Contact Information:</strong> Name, email address, and postal code when you use our email services</li>
<li><strong>Communication Content:</strong> Email messages you compose and send through our platform</li>
<li><strong>Usage Data:</strong> Information about how you interact with our service, including pages visited, features used, and timestamps</li>
<li><strong>Technical Data:</strong> IP address, browser type, device information, and other technical identifiers</li>
<li><strong>Representative Data:</strong> Information about elected officials obtained from public APIs and sources</li>
</ul>
<h3>3.2 How We Use Your Information</h3>
<p>Your information is used to:</p>
<ul>
<li>Facilitate communication between you and your elected representatives</li>
<li>Maintain records of campaign participation and email sending activities</li>
<li>Improve our service and user experience</li>
<li>Comply with legal obligations and respond to legal requests</li>
<li>Send you communications about campaigns, service updates, or related democratic engagement opportunities</li>
</ul>
<h3>3.3 Data Retention</h3>
<p>We retain your personal information for as long as necessary to provide our services and comply with legal obligations. Communication records may be retained indefinitely for transparency and accountability purposes.</p>
<h2>4. Communications from BNKops</h2>
<div class="highlight-box">
<strong>Notice:</strong> By using this service, you consent to receiving communications from BNKops regarding:
<ul>
<li>Service updates and improvements</li>
<li>New campaign opportunities</li>
<li>Democratic engagement initiatives</li>
<li>Technical notifications and security updates</li>
</ul>
<p>You may opt out of non-essential communications by contacting us using the information provided below.</p>
</div>
<h2>5. User Responsibilities</h2>
<p>As a user of this service, you agree to:</p>
<ul>
<li>Provide accurate and truthful information</li>
<li>Use the service only for legitimate democratic engagement purposes</li>
<li>Respect the time and resources of elected representatives</li>
<li>Not use the service for spam, harassment, or illegal activities</li>
<li>Not attempt to compromise the security or functionality of the service</li>
<li>Comply with all applicable Canadian federal, provincial, and municipal laws</li>
</ul>
<h2>6. Prohibited Uses</h2>
<p>You may not use this service to:</p>
<ul>
<li>Send threatening, abusive, or harassing communications</li>
<li>Distribute spam or unsolicited commercial content</li>
<li>Impersonate another person or provide false identity information</li>
<li>Attempt to gain unauthorized access to other users' data</li>
<li>Use automated tools to send bulk communications without authorization</li>
<li>Violate any applicable laws or regulations</li>
</ul>
<h2>7. Third-Party Services</h2>
<p>Our service integrates with third-party services including:</p>
<ul>
<li><strong>Represent API (Open North):</strong> For obtaining representative information</li>
<li><strong>Email Service Providers:</strong> For sending communications</li>
<li><strong>Database Services:</strong> For data storage and management</li>
</ul>
<p>These third parties have their own privacy policies and terms of service, which govern their collection and use of your information.</p>
<h2>8. Limitation of Liability</h2>
<p>BNKops provides this service "as is" without warranties of any kind. We are not responsible for:</p>
<ul>
<li>The accuracy or completeness of representative contact information</li>
<li>The delivery or response to communications sent through our platform</li>
<li>Any actions taken by elected representatives based on communications sent</li>
<li>Service interruptions or technical issues</li>
<li>Any damages resulting from your use of the service</li>
</ul>
<h2>9. Privacy Rights (Canadian Law)</h2>
<p>Under Canadian privacy law, you have the right to:</p>
<ul>
<li>Access your personal information held by us</li>
<li>Request correction of inaccurate information</li>
<li>Request deletion of your personal information (subject to legal retention requirements)</li>
<li>Withdraw consent for certain uses of your information</li>
<li>File a complaint with the Privacy Commissioner of Canada</li>
</ul>
<h2>10. Changes to Terms</h2>
<p>We reserve the right to modify these terms at any time. Changes will be posted on this page with an updated revision date. Continued use of the service after changes constitutes acceptance of the new terms.</p>
<h2>11. Governing Law</h2>
<p>These terms are governed by the laws of Canada and the province in which BNKops operates. Any disputes will be resolved in the appropriate Canadian courts.</p>
<h2>12. Contact Information</h2>
<div class="contact-info">
<strong>BNKops</strong><br>
For questions about these terms, privacy concerns, or to exercise your rights:<br>
<strong>Website:</strong> <a href="https://bnkops.com" target="_blank">https://bnkops.com</a><br>
<strong>Email:</strong> privacy@bnkops.com<br>
<br>
For technical support or service-related inquiries, please contact us through our website.
</div>
<h2>13. Severability</h2>
<p>If any provision of these terms is found to be unenforceable, the remaining provisions will continue in full force and effect.</p>
<div class="last-updated">
Last updated: September 23, 2025
</div>
</div>
<footer style="text-align: center; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #e9ecef;">
<p>&copy; 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a>. All rights reserved.</p>
<p><a href="/index.html" id="home-link">Return to Main Application</a></p>
</footer>
</div>
<script>
// Update navigation link with APP_URL if needed
fetch('/api/config')
.then(res => res.json())
.then(config => {
if (config.appUrl && !window.location.href.startsWith(config.appUrl)) {
document.getElementById('home-link').href = config.appUrl + '/index.html';
}
})
.catch(err => console.log('Config not loaded, using relative paths'));
</script>
</body>
</html>

View File

@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Verification - BNKops Influence</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/css/styles.css">
<style>
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.verification-container {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
max-width: 500px;
width: 90%;
text-align: center;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #d73027;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.success-icon {
font-size: 64px;
color: #28a745;
margin: 20px 0;
}
.error-icon {
font-size: 64px;
color: #dc3545;
margin: 20px 0;
}
.message {
margin: 20px 0;
color: #666;
line-height: 1.6;
}
.btn {
display: inline-block;
margin-top: 20px;
padding: 12px 24px;
background-color: #d73027;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: bold;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #c02822;
}
.logo {
color: #d73027;
font-size: 28px;
font-weight: bold;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="verification-container">
<div class="logo">BNKops Influence</div>
<div id="verification-status">
<div class="loading">
<div class="spinner"></div>
<p class="message">Verifying your email...</p>
</div>
</div>
</div>
</div>
<script src="/js/api-client.js"></script>
<script src="/js/verify-email.js"></script>
</body>
</html>

343
influence/app/routes/api.js Normal file
View File

@ -0,0 +1,343 @@
const express = require('express');
const router = express.Router();
const { body, param, validationResult } = require('express-validator');
const representativesController = require('../controllers/representatives');
const emailsController = require('../controllers/emails');
const campaignsController = require('../controllers/campaigns');
const customRecipientsController = require('../controllers/customRecipients');
const responsesController = require('../controllers/responses');
const rateLimiter = require('../utils/rate-limiter');
const { requireAdmin, requireAuth, requireNonTemp, optionalAuth } = require('../middleware/auth');
const upload = require('../middleware/upload');
// Import user routes
const userRoutes = require('./users');
// Import Listmonk routes
const listmonkRoutes = require('./listmonk');
// Validation middleware
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
};
// Test endpoints
router.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
router.get('/test-represent', representativesController.testConnection);
router.get('/test-smtp', requireAdmin, emailsController.testSMTPConnection);
// Representatives endpoints
router.get(
'/representatives/by-postal/:postalCode',
param('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/).withMessage('Invalid postal code format'),
handleValidationErrors,
rateLimiter.general,
representativesController.getByPostalCode
);
router.post(
'/representatives/refresh-postal/:postalCode',
param('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/).withMessage('Invalid postal code format'),
handleValidationErrors,
representativesController.refreshPostalCode
);
// Geocoding endpoint (proxy to Nominatim)
router.post(
'/geocode',
rateLimiter.general,
[
body('address').notEmpty().withMessage('Address is required')
],
handleValidationErrors,
representativesController.geocodeAddress
);
// Email endpoints
router.post(
'/emails/preview',
rateLimiter.general,
[
body('recipientEmail').isEmail().withMessage('Valid recipient email is required'),
body('senderName').notEmpty().withMessage('Sender name is required'),
body('senderEmail').isEmail().withMessage('Valid sender email is required'),
body('subject').notEmpty().withMessage('Subject is required'),
body('message').notEmpty().withMessage('Message is required'),
body('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/).withMessage('Invalid postal code format')
],
handleValidationErrors,
emailsController.previewEmail
);
router.post(
'/emails/send',
rateLimiter.email, // General hourly rate limit
rateLimiter.perRecipientEmailLimiter, // Per-recipient 5-minute rate limit
[
body('recipientEmail').isEmail().withMessage('Valid email is required'),
body('senderName').notEmpty().withMessage('Sender name is required'),
body('senderEmail').isEmail().withMessage('Valid sender email is required'),
body('subject').notEmpty().withMessage('Subject is required'),
body('message').notEmpty().withMessage('Message is required'),
body('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/).withMessage('Invalid postal code format')
],
handleValidationErrors,
emailsController.sendEmail
);
// Email testing endpoints
router.post(
'/emails/test',
requireAdmin,
rateLimiter.general,
[
body('subject').notEmpty().withMessage('Subject is required'),
body('message').notEmpty().withMessage('Message is required')
],
handleValidationErrors,
emailsController.sendTestEmail
);
// Email-to-campaign conversion endpoints
router.post(
'/emails/convert-to-campaign',
rateLimiter.general,
[
body('email').isEmail().withMessage('Valid email is required'),
body('subject').notEmpty().withMessage('Subject is required'),
body('message').notEmpty().withMessage('Message is required'),
body('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/).withMessage('Invalid postal code format')
],
handleValidationErrors,
emailsController.initiateEmailToCampaign
);
router.get(
'/verify-email/:token',
rateLimiter.general,
emailsController.verifyEmailToken
);
router.get(
'/emails/logs',
requireAdmin,
rateLimiter.general,
emailsController.getEmailLogs
);
// Campaign endpoints (Admin) - Protected
router.get('/admin/campaigns', requireAdmin, rateLimiter.general, campaignsController.getAllCampaigns);
router.get('/admin/campaigns/:id', requireAdmin, rateLimiter.general, campaignsController.getCampaignById);
router.post(
'/admin/campaigns',
requireAdmin,
campaignsController.upload.single('cover_photo'),
rateLimiter.general,
[
body('title').notEmpty().withMessage('Campaign title is required'),
body('email_subject').notEmpty().withMessage('Email subject is required'),
body('email_body').notEmpty().withMessage('Email body is required')
],
handleValidationErrors,
campaignsController.createCampaign
);
router.put('/admin/campaigns/:id', requireAdmin, campaignsController.upload.single('cover_photo'), rateLimiter.general, campaignsController.updateCampaign);
router.delete('/admin/campaigns/:id', requireAdmin, rateLimiter.general, campaignsController.deleteCampaign);
router.get('/admin/campaigns/:id/analytics', requireAdmin, rateLimiter.general, campaignsController.getCampaignAnalytics);
// Campaign endpoints (Authenticated users)
router.get('/campaigns', requireAuth, rateLimiter.general, campaignsController.getAllCampaigns);
router.post(
'/campaigns',
requireNonTemp,
campaignsController.upload.single('cover_photo'),
rateLimiter.general,
[
body('title').notEmpty().withMessage('Campaign title is required'),
body('email_subject').notEmpty().withMessage('Email subject is required'),
body('email_body').notEmpty().withMessage('Email body is required')
],
handleValidationErrors,
campaignsController.createCampaign
);
router.put(
'/campaigns/:id',
requireNonTemp,
campaignsController.upload.single('cover_photo'),
rateLimiter.general,
campaignsController.updateCampaign
);
router.get('/campaigns/:id/analytics', requireAuth, rateLimiter.general, campaignsController.getCampaignAnalytics);
// Campaign endpoints (Public)
router.get('/public/campaigns', rateLimiter.general, campaignsController.getPublicCampaigns);
router.get('/public/highlighted-campaign', rateLimiter.general, campaignsController.getHighlightedCampaign);
router.get('/campaigns/:slug', rateLimiter.general, campaignsController.getCampaignBySlug);
router.get('/campaigns/:slug/qrcode', rateLimiter.general, campaignsController.generateQRCode);
router.get('/campaigns/:slug/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign);
router.post(
'/campaigns/:slug/track-user',
rateLimiter.general,
[
body('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/).withMessage('Invalid postal code format')
],
handleValidationErrors,
campaignsController.trackUserInfo
);
router.post(
'/campaigns/:slug/send-email',
rateLimiter.email, // General hourly rate limit
rateLimiter.perRecipientEmailLimiter, // Per-recipient 5-minute rate limit
[
body('recipientEmail').isEmail().withMessage('Valid recipient email is required'),
body('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/).withMessage('Invalid postal code format'),
body('emailMethod').isIn(['smtp', 'mailto']).withMessage('Email method must be smtp or mailto')
],
handleValidationErrors,
campaignsController.sendCampaignEmail
);
// Campaign call tracking endpoint
router.post(
'/campaigns/:slug/track-call',
rateLimiter.general,
[
body('representativeName').notEmpty().withMessage('Representative name is required'),
body('phoneNumber').notEmpty().withMessage('Phone number is required')
],
handleValidationErrors,
campaignsController.trackCampaignCall
);
// General call tracking endpoint (non-campaign)
router.post(
'/track-call',
rateLimiter.general,
[
body('representativeName').notEmpty().withMessage('Representative name is required'),
body('phoneNumber').notEmpty().withMessage('Phone number is required')
],
handleValidationErrors,
representativesController.trackCall
);
// Custom Recipients Routes
router.get(
'/campaigns/:slug/custom-recipients',
requireNonTemp,
rateLimiter.general,
customRecipientsController.getRecipientsByCampaign
);
router.post(
'/campaigns/:slug/custom-recipients',
requireNonTemp,
rateLimiter.general,
[
body('recipient_name').notEmpty().withMessage('Recipient name is required'),
body('recipient_email').isEmail().withMessage('Valid recipient email is required')
],
handleValidationErrors,
customRecipientsController.createRecipient
);
router.post(
'/campaigns/:slug/custom-recipients/bulk',
requireNonTemp,
rateLimiter.general,
[
body('recipients').isArray().withMessage('Recipients must be an array'),
body('recipients.*.recipient_name').notEmpty().withMessage('Each recipient must have a name'),
body('recipients.*.recipient_email').isEmail().withMessage('Each recipient must have a valid email')
],
handleValidationErrors,
customRecipientsController.bulkCreateRecipients
);
router.put(
'/campaigns/:slug/custom-recipients/:id',
requireNonTemp,
rateLimiter.general,
customRecipientsController.updateRecipient
);
router.delete(
'/campaigns/:slug/custom-recipients/:id',
requireNonTemp,
rateLimiter.general,
customRecipientsController.deleteRecipient
);
router.delete(
'/campaigns/:slug/custom-recipients',
requireNonTemp,
rateLimiter.general,
customRecipientsController.deleteAllRecipients
);
// User management routes (admin only)
router.use('/admin/users', userRoutes);
// Listmonk email sync routes (admin only)
router.use('/listmonk', listmonkRoutes);
// Response Wall Routes
router.get('/campaigns/:slug/responses', rateLimiter.general, responsesController.getCampaignResponses);
router.get('/campaigns/:slug/response-stats', rateLimiter.general, responsesController.getResponseStats);
router.post(
'/campaigns/:slug/responses',
optionalAuth,
upload.single('screenshot'),
rateLimiter.general,
[
body('representative_name').notEmpty().withMessage('Representative name is required'),
body('representative_level').isIn(['Federal', 'Provincial', 'Municipal', 'School Board']).withMessage('Invalid representative level'),
body('response_type').isIn(['Email', 'Letter', 'Phone Call', 'Meeting', 'Social Media', 'Other']).withMessage('Invalid response type'),
body('response_text').notEmpty().withMessage('Response text is required')
],
handleValidationErrors,
responsesController.submitResponse
);
router.post('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.upvoteResponse);
router.delete('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.removeUpvote);
// Response Verification Routes (public - no auth required)
router.get('/responses/:id/verify/:token', responsesController.verifyResponse);
router.get('/responses/:id/report/:token', responsesController.reportResponse);
router.post('/responses/:id/resend-verification', rateLimiter.general, responsesController.resendVerification);
// Admin and Campaign Owner Response Management Routes
router.get('/admin/responses', requireNonTemp, rateLimiter.general, responsesController.getAdminResponses);
router.patch('/admin/responses/:id/status', requireNonTemp, rateLimiter.general,
[body('status').isIn(['pending', 'approved', 'rejected']).withMessage('Invalid status')],
handleValidationErrors,
responsesController.updateResponseStatus
);
router.patch('/admin/responses/:id', requireAdmin, rateLimiter.general, responsesController.updateResponse);
router.delete('/admin/responses/:id', requireAdmin, rateLimiter.general, responsesController.deleteResponse);
// Debug endpoint to check raw NocoDB data
router.get('/debug/responses', requireAdmin, async (req, res) => {
try {
const nocodbService = require('../services/nocodb');
// Get raw data without normalization
const rawResult = await nocodbService.getAll(nocodbService.tableIds.representativeResponses, {});
res.json({
success: true,
count: rawResult.list?.length || 0,
rawResponses: rawResult.list || [],
normalized: await nocodbService.getRepresentativeResponses({})
});
} catch (error) {
res.status(500).json({ error: error.message, stack: error.stack });
}
});
module.exports = router;

View File

@ -0,0 +1,19 @@
const express = require('express');
const authController = require('../controllers/authController');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
// POST /api/auth/login
router.post('/login', authController.login);
// POST /api/auth/logout
router.post('/logout', authController.logout);
// GET /api/auth/session
router.get('/session', authController.checkSession);
// POST /api/auth/change-password (requires authentication)
router.post('/change-password', requireAuth, authController.changePassword);
module.exports = router;

View File

@ -0,0 +1,13 @@
const express = require('express');
const router = express.Router();
const { requireAuth } = require('../middleware/auth');
// All dashboard routes require authentication
router.use(requireAuth);
// Serve the dashboard page
router.get('/', (req, res) => {
res.sendFile('dashboard.html', { root: './app/public' });
});
module.exports = router;

View File

@ -0,0 +1,26 @@
const express = require('express');
const router = express.Router();
const listmonkController = require('../controllers/listmonkController');
const { requireAdmin } = require('../middleware/auth');
// All Listmonk routes require admin authentication
router.use(requireAdmin);
// Get sync status
router.get('/status', listmonkController.getSyncStatus);
// Get list statistics
router.get('/stats', listmonkController.getListStats);
// Test connection
router.post('/test-connection', listmonkController.testConnection);
// Sync endpoints
router.post('/sync/participants', listmonkController.syncCampaignParticipants);
router.post('/sync/recipients', listmonkController.syncCustomRecipients);
router.post('/sync/all', listmonkController.syncAll);
// Reinitialize lists
router.post('/reinitialize', listmonkController.reinitializeLists);
module.exports = router;

View File

@ -0,0 +1,24 @@
const express = require('express');
const router = express.Router();
const usersController = require('../controllers/usersController');
const { requireAdmin } = require('../middleware/auth');
// All user routes require admin access
router.use(requireAdmin);
// Get all users
router.get('/', usersController.getAll);
// Create new user
router.post('/', usersController.create);
// Send login details to user
router.post('/:id/send-login-details', usersController.sendLoginDetails);
// Email all users
router.post('/email-all', usersController.emailAllUsers);
// Delete user
router.delete('/:id', usersController.delete);
module.exports = router;

249
influence/app/server.js Normal file
View File

@ -0,0 +1,249 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const session = require('express-session');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const path = require('path');
require('dotenv').config();
const logger = require('./utils/logger');
const metrics = require('./utils/metrics');
const healthCheck = require('./utils/health-check');
const { conditionalCsrfProtection, getCsrfToken, csrfProtection } = require('./middleware/csrf');
const apiRoutes = require('./routes/api');
const authRoutes = require('./routes/auth');
const { requireAdmin, requireAuth } = require('./middleware/auth');
const app = express();
const PORT = process.env.PORT || 3333;
// Trust proxy for Docker/reverse proxy environments
// Only trust Docker internal networks for better security
app.set('trust proxy', ['127.0.0.1', '::1', '172.16.0.0/12', '192.168.0.0/16', '10.0.0.0/8']);
// Compression middleware for better performance
app.use(compression({
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
},
level: 6 // Balance between speed and compression ratio
}));
// Security middleware
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://static.cloudflareinsights.com"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://cloudflareinsights.com"],
},
},
}));
// Middleware
// CORS configuration - Allow credentials for cookie-based CSRF
app.use(cors({
origin: true, // Allow requests from same origin
credentials: true // Allow cookies to be sent
}));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(cookieParser());
// Metrics middleware - track all HTTP requests
app.use(metrics.middleware);
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.logRequest(req, res, duration);
});
next();
});
// Session configuration - PRODUCTION HARDENED
app.use(session({
secret: process.env.SESSION_SECRET || 'influence-campaign-secret-key-change-in-production',
resave: false,
saveUninitialized: false,
name: 'influence.sid', // Custom session name for security
cookie: {
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true', // HTTPS only in production
httpOnly: true, // Prevent JavaScript access
maxAge: 3600000, // 1 hour (reduced from 24 hours)
sameSite: 'strict' // CSRF protection
}
}));
// CSRF Protection - Applied conditionally
app.use(conditionalCsrfProtection);
// Static files with proper caching
app.use(express.static(path.join(__dirname, 'public'), {
maxAge: process.env.NODE_ENV === 'production' ? '1d' : 0,
etag: true,
lastModified: true,
setHeaders: (res, filePath) => {
// Cache images and assets longer
if (filePath.match(/\.(jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$/)) {
res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days
}
// Cache CSS and JS for 1 day
else if (filePath.match(/\.(css|js)$/)) {
res.setHeader('Cache-Control', 'public, max-age=86400'); // 1 day
}
}
}));
// Health check endpoint - COMPREHENSIVE
app.get('/api/health', async (req, res) => {
try {
const health = await healthCheck.checkAll();
const statusCode = health.status === 'healthy' ? 200 : health.status === 'degraded' ? 200 : 503;
res.status(statusCode).json(health);
} catch (error) {
logger.error('Health check failed', { error: error.message });
res.status(503).json({
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString()
});
}
});
// Metrics endpoint for Prometheus
app.get('/api/metrics', async (req, res) => {
try {
res.set('Content-Type', metrics.getContentType());
const metricsData = await metrics.getMetrics();
res.end(metricsData);
} catch (error) {
logger.error('Metrics endpoint failed', { error: error.message });
res.status(500).json({ error: 'Failed to generate metrics' });
}
});
// Config endpoint - expose APP_URL to client
app.get('/api/config', (req, res) => {
res.json({
appUrl: process.env.APP_URL || `http://localhost:${PORT}`
});
});
// CSRF token endpoint - Needs CSRF middleware to generate token
app.get('/api/csrf-token', csrfProtection, (req, res) => {
logger.info('CSRF token endpoint hit');
getCsrfToken(req, res);
});
logger.info('CSRF token endpoint registered at /api/csrf-token');
// Auth routes must come before generic API routes
app.use('/api/auth', authRoutes);
// Generic API routes (catches all /api/*)
app.use('/api', apiRoutes);
// Serve the main page
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Serve login page
app.get('/login.html', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'login.html'));
});
// Serve admin panel (protected)
app.get('/admin.html', requireAdmin, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
});
// Serve the admin page (protected)
app.get('/admin', requireAdmin, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
});
// Serve user dashboard (protected)
app.get('/dashboard.html', requireAuth, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
});
app.get('/dashboard', requireAuth, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
});
// Serve campaign landing pages
app.get('/campaign/:slug', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'campaign.html'));
});
// Serve campaign pages
app.get('/campaign/:slug', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'campaign.html'));
});
// Error handling middleware
app.use((err, req, res, next) => {
logger.error('Application error', {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
ip: req.ip
});
res.status(err.status || 500).json({
error: 'Something went wrong!',
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
});
// 404 handler
app.use((req, res) => {
logger.warn('Route not found', {
path: req.path,
method: req.method,
ip: req.ip
});
res.status(404).json({ error: 'Route not found' });
});
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down gracefully');
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
});
process.on('SIGINT', () => {
logger.info('SIGINT received, shutting down gracefully');
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
});
const server = app.listen(PORT, () => {
logger.info('Server started', {
port: PORT,
environment: process.env.NODE_ENV,
nodeVersion: process.version
});
});
module.exports = app;

View File

@ -0,0 +1,470 @@
const nodemailer = require('nodemailer');
const emailTemplates = require('./emailTemplates');
class EmailService {
constructor() {
this.transporter = null;
this.initializeTransporter();
}
initializeTransporter() {
try {
const transporterConfig = {
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_PORT === '465', // true for 465, false for other ports
tls: {
rejectUnauthorized: false
}
};
// Add auth if credentials are provided
if (process.env.SMTP_USER && process.env.SMTP_PASS) {
transporterConfig.auth = {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
};
}
this.transporter = nodemailer.createTransport(transporterConfig);
console.log('Email transporter initialized successfully');
} catch (error) {
console.error('Failed to initialize email transporter:', error);
}
}
async testConnection() {
try {
if (!this.transporter) {
throw new Error('Email transporter not initialized');
}
await this.transporter.verify();
return {
success: true,
message: 'SMTP connection verified successfully'
};
} catch (error) {
return {
success: false,
message: 'SMTP connection failed',
error: error.message
};
}
}
async sendEmail(emailOptions, isTest = false) {
try {
if (!this.transporter) {
throw new Error('Email transporter not initialized');
}
let to = emailOptions.to;
let subject = emailOptions.subject;
// Test mode - redirect emails and modify subject
const testMode = isTest || process.env.EMAIL_TEST_MODE === 'true';
if (testMode) {
const originalTo = to;
to = process.env.TEST_EMAIL_RECIPIENT || 'admin@example.com';
subject = `[TEST - Original: ${originalTo}] ${subject}`;
console.log(`Email redirected from ${originalTo} to ${to} (Test Mode)`);
}
const mailOptions = {
from: `"${emailOptions.from.name}" <${emailOptions.from.email}>`,
to: to,
replyTo: emailOptions.replyTo,
subject: subject,
text: emailOptions.text,
html: emailOptions.html
};
// Log email details in development
if (process.env.NODE_ENV === 'development') {
console.log('Email Preview:', {
to: mailOptions.to,
subject: mailOptions.subject,
preview: emailOptions.text ? emailOptions.text.substring(0, 200) + '...' : 'No text content',
testMode: testMode
});
}
console.log('DEBUG: About to send email via SMTP...');
const info = await this.transporter.sendMail(mailOptions);
console.log('DEBUG: Email sent via SMTP successfully:', info.messageId);
console.log('DEBUG: Email info response:', info.response);
// Log email to database if NocoDB service is available
console.log('DEBUG: About to log email to database...');
try {
await this.logEmailSent({
to: emailOptions.to, // Log original recipient
subject: emailOptions.subject, // Log original subject
status: 'sent',
messageId: info.messageId,
testMode: testMode,
senderName: emailOptions.from?.name || 'System',
senderEmail: emailOptions.from?.email || process.env.SMTP_FROM_EMAIL
});
console.log('DEBUG: Successfully logged email to database');
} catch (logError) {
console.error('DEBUG: Failed to log email to database:', logError);
// Continue anyway - don't let logging failure affect email success
}
const successResult = {
success: true,
messageId: info.messageId,
response: info.response,
testMode: testMode,
originalRecipient: testMode ? emailOptions.to : undefined
};
console.log('DEBUG: Returning success result:', successResult);
return successResult;
} catch (error) {
console.error('Email send error:', error);
// Log failed email attempt
await this.logEmailSent({
to: emailOptions.to,
subject: emailOptions.subject,
status: 'failed',
error: error.message,
testMode: isTest || process.env.EMAIL_TEST_MODE === 'true',
senderName: emailOptions.from?.name || 'System',
senderEmail: emailOptions.from?.email || process.env.SMTP_FROM_EMAIL
});
return {
success: false,
error: error.message
};
}
}
async sendBulkEmails(emails) {
const results = [];
for (const email of emails) {
try {
const result = await this.sendEmail(email);
results.push({
to: email.to,
success: result.success,
messageId: result.messageId,
error: result.error
});
// Add a small delay between emails to avoid overwhelming the SMTP server
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
results.push({
to: email.to,
success: false,
error: error.message
});
}
}
return results;
}
formatEmailTemplate(template, data) {
let formattedTemplate = template;
// Replace placeholders with actual data
Object.keys(data).forEach(key => {
const placeholder = `{{${key}}}`;
formattedTemplate = formattedTemplate.replace(new RegExp(placeholder, 'g'), data[key]);
});
return formattedTemplate;
}
validateEmailAddress(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
async logEmailSent(emailData) {
try {
// Use the existing logEmailSend method with the correct field structure
const nocodbService = require('./nocodb');
if (nocodbService && process.env.NOCODB_TABLE_EMAILS) {
await nocodbService.logEmailSend({
recipientEmail: emailData.to,
senderName: emailData.senderName || 'System',
senderEmail: emailData.senderEmail || process.env.SMTP_FROM_EMAIL,
subject: emailData.subject,
postalCode: emailData.postalCode || 'N/A',
status: emailData.status || 'sent',
timestamp: new Date().toISOString(),
senderIP: emailData.senderIP || 'localhost'
});
}
} catch (error) {
console.error('Failed to log email:', error);
// Don't throw - logging failure shouldn't prevent email sending
}
}
async previewEmail(emailOptions) {
// Generate email preview without sending
return {
to: emailOptions.to,
subject: emailOptions.subject,
body: emailOptions.text,
html: emailOptions.html,
from: `"${emailOptions.from.name}" <${emailOptions.from.email}>`,
replyTo: emailOptions.replyTo,
timestamp: new Date().toISOString(),
testMode: process.env.EMAIL_TEST_MODE === 'true',
redirectTo: process.env.EMAIL_TEST_MODE === 'true' ? process.env.TEST_EMAIL_RECIPIENT : null
};
}
// Template-based email methods
async sendTemplatedEmail(templateName, templateVariables, emailOptions, isTest = false) {
console.log('DEBUG: sendTemplatedEmail called with template:', templateName);
try {
// Render the template
console.log('DEBUG: About to render template...');
const { html, text } = await emailTemplates.render(templateName, templateVariables);
console.log('DEBUG: Template rendered successfully');
// Prepare email options with rendered content
const mailOptions = {
...emailOptions,
text: text,
html: html
};
// Send the email using existing sendEmail method
console.log('DEBUG: About to call sendEmail from sendTemplatedEmail...');
const result = await this.sendEmail(mailOptions, isTest);
console.log('DEBUG: sendEmail returned result:', result);
return result;
} catch (error) {
console.error('DEBUG: Failed to send templated email:', error);
return {
success: false,
error: error.message
};
}
}
async sendRepresentativeEmail(recipientEmail, senderName, senderEmail, subject, message, postalCode, recipientName = null) {
// Generate dynamic subject if not provided
const finalSubject = subject || `Message from ${senderName} from ${postalCode}`;
const templateVariables = {
MESSAGE: message,
SENDER_NAME: senderName,
SENDER_EMAIL: senderEmail,
POSTAL_CODE: postalCode,
RECIPIENT_NAME: recipientName || 'Representative'
};
const emailOptions = {
to: recipientEmail,
from: {
email: process.env.SMTP_FROM_EMAIL,
name: process.env.SMTP_FROM_NAME
},
replyTo: senderEmail,
subject: finalSubject
};
return await this.sendTemplatedEmail('representative-contact', templateVariables, emailOptions);
}
async sendCampaignEmail(recipientEmail, userEmail, userName, postalCode, subject, message, campaignTitle, recipientName = null, recipientLevel = null) {
console.log('DEBUG: sendCampaignEmail called for recipient:', recipientEmail);
const templateVariables = {
MESSAGE: message,
USER_NAME: userName,
USER_EMAIL: userEmail,
POSTAL_CODE: postalCode,
CAMPAIGN_TITLE: campaignTitle,
RECIPIENT_NAME: recipientName,
RECIPIENT_LEVEL: recipientLevel
};
const emailOptions = {
to: recipientEmail,
from: {
email: process.env.SMTP_FROM_EMAIL,
name: process.env.SMTP_FROM_NAME
},
replyTo: userEmail,
subject: subject
};
console.log('DEBUG: About to call sendTemplatedEmail from sendCampaignEmail...');
const result = await this.sendTemplatedEmail('campaign-email', templateVariables, emailOptions);
console.log('DEBUG: sendCampaignEmail received result:', result);
return result;
}
async sendTestEmail(subject, message, testRecipient = null) {
const recipient = testRecipient || process.env.TEST_EMAIL_RECIPIENT || 'admin@example.com';
const templateVariables = {
MESSAGE: message
};
const emailOptions = {
to: recipient,
from: {
email: process.env.SMTP_FROM_EMAIL,
name: process.env.SMTP_FROM_NAME
},
replyTo: process.env.SMTP_FROM_EMAIL,
subject: `[TEST EMAIL] ${subject}`
};
return await this.sendTemplatedEmail('test-email', templateVariables, emailOptions, true);
}
async previewTemplatedEmail(templateName, templateVariables, emailOptions) {
try {
const { html, text } = await emailTemplates.render(templateName, templateVariables);
return {
to: emailOptions.to,
subject: emailOptions.subject,
body: text,
html: html,
from: `"${emailOptions.from.name}" <${emailOptions.from.email}>`,
replyTo: emailOptions.replyTo,
timestamp: new Date().toISOString(),
testMode: process.env.EMAIL_TEST_MODE === 'true',
redirectTo: process.env.EMAIL_TEST_MODE === 'true' ? process.env.TEST_EMAIL_RECIPIENT : null,
templateName: templateName,
templateVariables: templateVariables
};
} catch (error) {
console.error('Failed to preview templated email:', error);
throw error;
}
}
// User management email methods
async sendLoginDetails(user) {
try {
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3333}`;
const isAdmin = user.admin || user.Admin || false;
const templateVariables = {
APP_NAME: 'BNKops Influence',
USER_NAME: user.Name || user.name || user.Email || user.email,
USER_EMAIL: user.Email || user.email,
PASSWORD: user.Password || user.password,
USER_ROLE: isAdmin ? 'Administrator' : 'User',
LOGIN_URL: `${baseUrl}/login.html`,
TIMESTAMP: new Date().toLocaleString()
};
const emailOptions = {
to: user.Email || user.email,
from: {
email: process.env.SMTP_FROM_EMAIL,
name: process.env.SMTP_FROM_NAME
},
subject: `Your Login Details - ${templateVariables.APP_NAME}`
};
return await this.sendTemplatedEmail('login-details', templateVariables, emailOptions);
} catch (error) {
console.error('Failed to send login details email:', error);
throw error;
}
}
async sendEmailVerification(recipientEmail, verificationUrl, userName) {
try {
const templateVariables = {
USER_NAME: userName || 'there',
VERIFICATION_URL: verificationUrl
};
const emailOptions = {
to: recipientEmail,
from: {
email: process.env.SMTP_FROM_EMAIL,
name: process.env.SMTP_FROM_NAME
},
subject: 'Verify Your Email to Create Your Campaign'
};
return await this.sendTemplatedEmail('email-verification', templateVariables, emailOptions);
} catch (error) {
console.error('Failed to send email verification:', error);
throw error;
}
}
/**
* Send response verification email to representative
* @param {Object} options - Email options
* @param {string} options.representativeEmail - Representative's email address
* @param {string} options.representativeName - Representative's name
* @param {string} options.campaignTitle - Campaign title
* @param {string} options.responseType - Type of response (Email, Letter, etc.)
* @param {string} options.responseText - The actual response text
* @param {string} options.submittedDate - Date the response was submitted
* @param {string} options.submitterName - Name of person who submitted
* @param {string} options.verificationUrl - URL to verify the response
* @param {string} options.reportUrl - URL to report as invalid
*/
async sendResponseVerification(options) {
try {
const {
representativeEmail,
representativeName,
campaignTitle,
responseType,
responseText,
submittedDate,
submitterName,
verificationUrl,
reportUrl
} = options;
const templateVariables = {
REPRESENTATIVE_NAME: representativeName,
CAMPAIGN_TITLE: campaignTitle,
RESPONSE_TYPE: responseType,
RESPONSE_TEXT: responseText,
SUBMITTED_DATE: submittedDate,
SUBMITTER_NAME: submitterName || 'Anonymous',
VERIFICATION_URL: verificationUrl,
REPORT_URL: reportUrl,
APP_NAME: process.env.APP_NAME || 'BNKops Influence',
TIMESTAMP: new Date().toLocaleString()
};
const emailOptions = {
to: representativeEmail,
from: {
email: process.env.SMTP_FROM_EMAIL,
name: process.env.SMTP_FROM_NAME
},
subject: `Response Verification Request - ${campaignTitle}`
};
return await this.sendTemplatedEmail('response-verification', templateVariables, emailOptions);
} catch (error) {
console.error('Failed to send response verification email:', error);
throw error;
}
}
}
module.exports = new EmailService();

View File

@ -0,0 +1,368 @@
const Queue = require('bull');
const logger = require('./logger');
const metrics = require('./metrics');
const emailService = require('../services/email');
// Configure Redis connection for Bull
const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB || '0'),
// Retry strategy for connection failures
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
// Enable offline queue
enableOfflineQueue: true,
maxRetriesPerRequest: 3
};
// Create email queue
const emailQueue = new Queue('email-queue', {
redis: redisConfig,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000 // Start with 2 seconds, then 4, 8, etc.
},
removeOnComplete: {
age: 24 * 3600, // Keep completed jobs for 24 hours
count: 1000 // Keep last 1000 completed jobs
},
removeOnFail: {
age: 7 * 24 * 3600 // Keep failed jobs for 7 days
},
timeout: 30000 // 30 seconds timeout per job
}
});
// Process email jobs
emailQueue.process(async (job) => {
const { type, data } = job.data;
const start = Date.now();
logger.info('Processing email job', {
jobId: job.id,
type,
attempt: job.attemptsMade + 1,
maxAttempts: job.opts.attempts
});
try {
let result;
switch (type) {
case 'campaign':
result = await emailService.sendCampaignEmail(data);
break;
case 'verification':
result = await emailService.sendVerificationEmail(data);
break;
case 'login-details':
result = await emailService.sendLoginDetails(data);
break;
case 'broadcast':
result = await emailService.sendBroadcast(data);
break;
case 'response-verification':
result = await emailService.sendResponseVerificationEmail(data);
break;
default:
throw new Error(`Unknown email type: ${type}`);
}
const duration = (Date.now() - start) / 1000;
// Record metrics
metrics.recordEmailSent(
data.campaignId || 'system',
data.representativeLevel || 'unknown'
);
metrics.observeEmailSendDuration(
data.campaignId || 'system',
duration
);
logger.logEmailSent(
data.to || data.email,
data.campaignId || type,
'success'
);
return result;
} catch (error) {
const duration = (Date.now() - start) / 1000;
// Record failure metrics
metrics.recordEmailFailed(
data.campaignId || 'system',
error.code || 'unknown'
);
logger.logEmailFailed(
data.to || data.email,
data.campaignId || type,
error
);
// Throw error to trigger retry
throw error;
}
});
// Queue event handlers
emailQueue.on('completed', (job, result) => {
logger.info('Email job completed', {
jobId: job.id,
type: job.data.type,
duration: Date.now() - job.timestamp
});
});
emailQueue.on('failed', (job, err) => {
logger.error('Email job failed', {
jobId: job.id,
type: job.data.type,
attempt: job.attemptsMade,
maxAttempts: job.opts.attempts,
error: err.message,
willRetry: job.attemptsMade < job.opts.attempts
});
});
emailQueue.on('stalled', (job) => {
logger.warn('Email job stalled', {
jobId: job.id,
type: job.data.type
});
});
emailQueue.on('error', (error) => {
logger.error('Email queue error', { error: error.message });
});
// Update queue size metric every 10 seconds
setInterval(async () => {
try {
const counts = await emailQueue.getJobCounts();
const queueSize = counts.waiting + counts.active;
metrics.setEmailQueueSize(queueSize);
} catch (error) {
logger.warn('Failed to update queue metrics', { error: error.message });
}
}, 10000);
/**
* Email Queue Service
* Provides methods to enqueue different types of emails
*/
class EmailQueueService {
/**
* Send campaign email (to representative)
*/
async sendCampaignEmail(emailData) {
const job = await emailQueue.add(
{
type: 'campaign',
data: emailData
},
{
priority: 2, // Normal priority
jobId: `campaign-${emailData.campaignId}-${Date.now()}`
}
);
logger.info('Campaign email queued', {
jobId: job.id,
campaignId: emailData.campaignId,
recipient: emailData.to
});
return { jobId: job.id, queued: true };
}
/**
* Send email verification
*/
async sendVerificationEmail(emailData) {
const job = await emailQueue.add(
{
type: 'verification',
data: emailData
},
{
priority: 1, // High priority - user waiting
jobId: `verification-${emailData.email}-${Date.now()}`
}
);
logger.info('Verification email queued', {
jobId: job.id,
email: emailData.email
});
return { jobId: job.id, queued: true };
}
/**
* Send login details
*/
async sendLoginDetails(emailData) {
const job = await emailQueue.add(
{
type: 'login-details',
data: emailData
},
{
priority: 1, // High priority
jobId: `login-${emailData.email}-${Date.now()}`
}
);
logger.info('Login details email queued', {
jobId: job.id,
email: emailData.email
});
return { jobId: job.id, queued: true };
}
/**
* Send broadcast to users
*/
async sendBroadcast(emailData) {
const job = await emailQueue.add(
{
type: 'broadcast',
data: emailData
},
{
priority: 3, // Lower priority - batch operation
jobId: `broadcast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
);
logger.info('Broadcast email queued', {
jobId: job.id,
recipientCount: emailData.recipients?.length || 1
});
return { jobId: job.id, queued: true };
}
/**
* Send response verification email
*/
async sendResponseVerificationEmail(emailData) {
const job = await emailQueue.add(
{
type: 'response-verification',
data: emailData
},
{
priority: 2,
jobId: `response-verification-${emailData.email}-${Date.now()}`
}
);
logger.info('Response verification email queued', {
jobId: job.id,
email: emailData.email
});
return { jobId: job.id, queued: true };
}
/**
* Get job status
*/
async getJobStatus(jobId) {
const job = await emailQueue.getJob(jobId);
if (!job) {
return { status: 'not_found' };
}
const state = await job.getState();
const progress = job.progress();
return {
jobId: job.id,
status: state,
progress,
attempts: job.attemptsMade,
data: job.data,
createdAt: job.timestamp,
processedAt: job.processedOn,
finishedAt: job.finishedOn,
failedReason: job.failedReason
};
}
/**
* Get queue statistics
*/
async getQueueStats() {
const counts = await emailQueue.getJobCounts();
const jobs = {
waiting: await emailQueue.getWaiting(0, 10),
active: await emailQueue.getActive(0, 10),
completed: await emailQueue.getCompleted(0, 10),
failed: await emailQueue.getFailed(0, 10)
};
return {
counts,
samples: {
waiting: jobs.waiting.map(j => ({ id: j.id, type: j.data.type })),
active: jobs.active.map(j => ({ id: j.id, type: j.data.type })),
completed: jobs.completed.slice(0, 5).map(j => ({ id: j.id, type: j.data.type })),
failed: jobs.failed.slice(0, 5).map(j => ({ id: j.id, type: j.data.type, reason: j.failedReason }))
}
};
}
/**
* Clean old jobs
*/
async cleanQueue(grace = 24 * 3600 * 1000) {
const cleaned = await emailQueue.clean(grace, 'completed');
logger.info('Queue cleaned', { removedJobs: cleaned.length });
return { cleaned: cleaned.length };
}
/**
* Pause queue
*/
async pauseQueue() {
await emailQueue.pause();
logger.warn('Email queue paused');
return { paused: true };
}
/**
* Resume queue
*/
async resumeQueue() {
await emailQueue.resume();
logger.info('Email queue resumed');
return { resumed: true };
}
/**
* Get queue instance (for advanced operations)
*/
getQueue() {
return emailQueue;
}
}
module.exports = new EmailQueueService();

View File

@ -0,0 +1,135 @@
const fs = require('fs').promises;
const path = require('path');
class EmailTemplateService {
constructor() {
this.templatesDir = path.join(__dirname, '../templates/email');
this.cache = new Map();
}
async loadTemplate(templateName, type = 'html') {
const cacheKey = `${templateName}.${type}`;
// Check cache first
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}
try {
const templatePath = path.join(this.templatesDir, `${templateName}.${type}`);
const template = await fs.readFile(templatePath, 'utf-8');
// Cache the template
this.cache.set(cacheKey, template);
return template;
} catch (error) {
console.error(`Failed to load email template ${templateName}.${type}:`, error);
throw new Error(`Email template not found: ${templateName}.${type}`);
}
}
processTemplate(template, variables) {
if (!template) return '';
let processed = template;
// Handle conditional blocks {{#if VARIABLE}}...{{/if}}
processed = processed.replace(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (match, varName, content) => {
const value = variables[varName];
// Check if value exists and is not empty string
if (value !== undefined && value !== null && value !== '') {
// Recursively process the content inside the conditional block
return this.processTemplate(content, variables);
}
return '';
});
// Replace variables {{VARIABLE}}
processed = processed.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
const value = variables[varName];
// Return the value or empty string if undefined
return value !== undefined && value !== null ? String(value) : '';
});
// Handle line breaks in MESSAGE field for HTML templates
if (variables.MESSAGE && processed.includes('{{MESSAGE}}')) {
processed = processed.replace(/\{\{MESSAGE\}\}/g, variables.MESSAGE.replace(/\n/g, '<br>'));
}
return processed;
}
async render(templateName, variables) {
try {
// Load both HTML and text versions
const [htmlTemplate, textTemplate] = await Promise.all([
this.loadTemplate(templateName, 'html'),
this.loadTemplate(templateName, 'txt')
]);
// Add default variables
const defaultVariables = {
APP_NAME: process.env.APP_NAME || 'BNKops Influence Campaign',
TIMESTAMP: new Date().toLocaleString(),
...variables
};
// Use processTemplate which handles conditionals properly
return {
html: this.processTemplate(htmlTemplate, defaultVariables),
text: this.processTemplate(textTemplate, defaultVariables)
};
} catch (error) {
console.error('Failed to render email template:', error);
throw error;
}
}
// Get available template names
async getAvailableTemplates() {
try {
const files = await fs.readdir(this.templatesDir);
const templates = new Set();
files.forEach(file => {
const ext = path.extname(file);
const name = path.basename(file, ext);
if (ext === '.html' || ext === '.txt') {
templates.add(name);
}
});
return Array.from(templates);
} catch (error) {
console.error('Failed to get available templates:', error);
return [];
}
}
// Clear template cache (useful for development)
clearCache() {
this.cache.clear();
console.log('Email template cache cleared');
}
// Check if template exists
async templateExists(templateName) {
try {
const htmlPath = path.join(this.templatesDir, `${templateName}.html`);
const txtPath = path.join(this.templatesDir, `${templateName}.txt`);
// Check if at least one format exists
const [htmlExists, txtExists] = await Promise.all([
fs.access(htmlPath).then(() => true).catch(() => false),
fs.access(txtPath).then(() => true).catch(() => false)
]);
return htmlExists || txtExists;
} catch (error) {
return false;
}
}
}
module.exports = new EmailTemplateService();

View File

@ -0,0 +1,832 @@
const axios = require('axios');
const logger = require('../utils/logger');
class ListmonkService {
constructor() {
this.baseURL = process.env.LISTMONK_API_URL || 'http://listmonk:9000/api';
this.username = process.env.LISTMONK_USERNAME;
this.password = process.env.LISTMONK_PASSWORD;
this.lists = {
allCampaigns: null,
activeCampaigns: null,
customRecipients: null,
campaignParticipants: null,
emailLogs: null, // For generic email logs (non-campaign)
campaignLists: {} // Dynamic per-campaign lists
};
// Debug logging for environment variables
console.log('🔍 Listmonk Environment Variables (Influence):');
console.log(` LISTMONK_SYNC_ENABLED: ${process.env.LISTMONK_SYNC_ENABLED}`);
console.log(` LISTMONK_INITIAL_SYNC: ${process.env.LISTMONK_INITIAL_SYNC}`);
console.log(` LISTMONK_API_URL: ${process.env.LISTMONK_API_URL}`);
console.log(` LISTMONK_USERNAME: ${this.username ? 'SET' : 'NOT SET'}`);
console.log(` LISTMONK_PASSWORD: ${this.password ? 'SET' : 'NOT SET'}`);
this.syncEnabled = process.env.LISTMONK_SYNC_ENABLED === 'true';
// Additional validation - disable if credentials are missing
if (this.syncEnabled && (!this.username || !this.password)) {
logger.warn('Listmonk credentials missing - disabling sync');
this.syncEnabled = false;
}
console.log(` Final syncEnabled: ${this.syncEnabled}`);
this.lastError = null;
this.lastErrorTime = null;
}
// Validate and clean email address
validateAndCleanEmail(email) {
if (!email || typeof email !== 'string') {
return { valid: false, cleaned: null, error: 'Email is required' };
}
// Trim whitespace and convert to lowercase
let cleaned = email.trim().toLowerCase();
// Basic email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(cleaned)) {
return { valid: false, cleaned: null, error: 'Invalid email format' };
}
// Check for common typos in domain extensions
const commonTypos = {
'.co': '.ca',
'.cA': '.ca',
'.Ca': '.ca',
'.cOM': '.com',
'.coM': '.com',
'.cOm': '.com',
'.neT': '.net',
'.nEt': '.net',
'.ORg': '.org',
'.oRg': '.org'
};
// Fix common domain extension typos
for (const [typo, correction] of Object.entries(commonTypos)) {
if (cleaned.endsWith(typo)) {
const fixedEmail = cleaned.slice(0, -typo.length) + correction;
logger.warn(`Email validation: Fixed typo in ${email} -> ${fixedEmail}`);
cleaned = fixedEmail;
break;
}
}
// Additional validation: check for suspicious patterns
if (cleaned.includes('..') || cleaned.startsWith('.') || cleaned.endsWith('.')) {
return { valid: false, cleaned: null, error: 'Invalid email pattern' };
}
return { valid: true, cleaned, error: null };
}
// Create axios instance with auth
getClient() {
return axios.create({
baseURL: this.baseURL,
auth: {
username: this.username,
password: this.password
},
headers: {
'Content-Type': 'application/json'
},
timeout: 10000 // 10 second timeout
});
}
// Test connection to Listmonk
async checkConnection() {
if (!this.syncEnabled) {
return false;
}
try {
console.log(`🔍 Testing connection to: ${this.baseURL}`);
console.log(`🔍 Using credentials: ${this.username}:${this.password ? 'SET' : 'NOT SET'}`);
const client = this.getClient();
console.log('🔍 Making request to /health endpoint...');
const { data } = await client.get('/health');
console.log('🔍 Response received:', JSON.stringify(data, null, 2));
if (data.data === true) {
logger.info('Listmonk connection successful');
this.lastError = null;
this.lastErrorTime = null;
return true;
}
console.log('🔍 Health check failed - data.data is not true');
return false;
} catch (error) {
console.log('🔍 Connection error details:', error.message);
if (error.response) {
console.log('🔍 Response status:', error.response.status);
console.log('🔍 Response data:', error.response.data);
}
this.lastError = `Listmonk connection failed: ${error.message}`;
this.lastErrorTime = new Date();
logger.error(this.lastError);
return false;
}
}
// Initialize all lists on startup
async initializeLists() {
if (!this.syncEnabled) {
logger.info('Listmonk sync is disabled');
return false;
}
try {
// Check connection first
const connected = await this.checkConnection();
if (!connected) {
throw new Error(`Cannot connect to Listmonk: ${this.lastError || 'Unknown connection error'}`);
}
// Create main campaign lists
this.lists.allCampaigns = await this.ensureList({
name: 'Influence - All Campaigns',
type: 'private',
optin: 'single',
tags: ['influence', 'campaigns', 'automated'],
description: 'All campaign participants from the influence tool'
});
this.lists.activeCampaigns = await this.ensureList({
name: 'Influence - Active Campaigns',
type: 'private',
optin: 'single',
tags: ['influence', 'active', 'automated'],
description: 'Participants in active campaigns only'
});
this.lists.customRecipients = await this.ensureList({
name: 'Influence - Custom Recipients',
type: 'private',
optin: 'single',
tags: ['influence', 'custom-recipients', 'automated'],
description: 'Custom recipients added to campaigns'
});
this.lists.campaignParticipants = await this.ensureList({
name: 'Influence - Campaign Participants',
type: 'private',
optin: 'single',
tags: ['influence', 'participants', 'automated'],
description: 'Users who have participated in sending campaign emails'
});
this.lists.emailLogs = await this.ensureList({
name: 'Influence - Email Logs',
type: 'private',
optin: 'single',
tags: ['influence', 'email-logs', 'automated'],
description: 'All email activity from the public influence service'
});
logger.info('✅ Listmonk main lists initialized successfully');
// Initialize campaign-specific lists for all campaigns
try {
const nocodbService = require('./nocodb');
const campaigns = await nocodbService.getAllCampaigns();
if (campaigns && campaigns.length > 0) {
logger.info(`🔄 Initializing lists for ${campaigns.length} campaigns...`);
for (const campaign of campaigns) {
const slug = campaign['Campaign Slug'];
const title = campaign['Campaign Title'];
const status = campaign['Status'];
if (slug && title) {
try {
const campaignList = await this.ensureCampaignList(slug, title);
if (campaignList) {
logger.info(`📋 Initialized list for campaign: ${title} (${status})`);
}
} catch (error) {
logger.warn(`Failed to initialize list for campaign ${title}:`, error.message);
}
}
}
logger.info(`✅ Campaign lists initialized: ${Object.keys(this.lists.campaignLists).length} lists`);
}
} catch (error) {
logger.warn('Failed to initialize campaign-specific lists:', error.message);
// Don't fail the entire initialization if campaign lists fail
}
return true;
} catch (error) {
this.lastError = `Failed to initialize Listmonk lists: ${error.message}`;
this.lastErrorTime = new Date();
logger.error(this.lastError);
return false;
}
}
// Ensure a list exists, create if not
async ensureList(listConfig) {
try {
const client = this.getClient();
// First, try to find existing list by name
const { data: listsResponse } = await client.get('/lists');
const existingList = listsResponse.data.results.find(list => list.name === listConfig.name);
if (existingList) {
logger.info(`📋 Found existing list: ${listConfig.name}`);
return existingList;
}
// Create new list
const { data: createResponse } = await client.post('/lists', listConfig);
logger.info(`📋 Created new list: ${listConfig.name}`);
return createResponse.data;
} catch (error) {
logger.error(`Failed to ensure list ${listConfig.name}:`, error.message);
throw error;
}
}
// Ensure a list exists for a specific campaign
async ensureCampaignList(campaignSlug, campaignTitle) {
if (!this.syncEnabled) {
return null;
}
// Check if we already have this campaign list cached
if (this.lists.campaignLists[campaignSlug]) {
return this.lists.campaignLists[campaignSlug];
}
try {
const listConfig = {
name: `Campaign: ${campaignTitle}`,
type: 'private',
optin: 'single',
tags: ['influence', 'campaign', campaignSlug, 'automated'],
description: `Participants who sent emails for the "${campaignTitle}" campaign`
};
const list = await this.ensureList(listConfig);
this.lists.campaignLists[campaignSlug] = list;
logger.info(`✅ Campaign list created/found for: ${campaignTitle}`);
return list;
} catch (error) {
logger.error(`Failed to ensure campaign list for ${campaignSlug}:`, error.message);
return null;
}
}
// Sync a campaign participant to Listmonk
async syncCampaignParticipant(emailData, campaignData) {
// Map NocoDB field names (column titles) to properties
// Try User fields first (new Campaign Emails table), fall back to Sender fields (old table)
const userEmail = emailData['User Email'] || emailData['Sender Email'] || emailData.sender_email;
const userName = emailData['User Name'] || emailData['Sender Name'] || emailData.sender_name;
const userPostalCode = emailData['User Postal Code'] || emailData['Postal Code'] || emailData.postal_code;
const createdAt = emailData['CreatedAt'] || emailData.created_at;
const sentTo = emailData['Sent To'] || emailData.sent_to;
const recipientEmail = emailData['Recipient Email'];
const recipientName = emailData['Recipient Name'];
if (!this.syncEnabled || !userEmail) {
return { success: false, error: 'Sync disabled or no email provided' };
}
// Validate and clean the email address
const emailValidation = this.validateAndCleanEmail(userEmail);
if (!emailValidation.valid) {
logger.warn(`Skipping invalid email: ${userEmail} - ${emailValidation.error}`);
return { success: false, error: emailValidation.error };
}
try {
const subscriberLists = [this.lists.allCampaigns.id, this.lists.campaignParticipants.id];
// Add to active campaigns list if campaign is active
const campaignStatus = campaignData?.Status;
if (campaignStatus === 'active') {
subscriberLists.push(this.lists.activeCampaigns.id);
}
// Add to campaign-specific list
const campaignSlug = campaignData?.['Campaign Slug'];
const campaignTitle = campaignData?.['Campaign Title'];
if (campaignSlug && campaignTitle) {
const campaignList = await this.ensureCampaignList(campaignSlug, campaignTitle);
if (campaignList) {
subscriberLists.push(campaignList.id);
logger.info(`📧 Added ${emailValidation.cleaned} to campaign list: ${campaignTitle}`);
}
}
const subscriberData = {
email: emailValidation.cleaned,
name: userName || emailValidation.cleaned,
status: 'enabled',
lists: subscriberLists,
attribs: {
last_campaign: campaignTitle || 'Unknown',
campaign_slug: campaignSlug || null,
last_sent: createdAt ? new Date(createdAt).toISOString() : new Date().toISOString(),
postal_code: userPostalCode || null,
sent_to_representatives: sentTo || null,
last_recipient_email: recipientEmail || null,
last_recipient_name: recipientName || null
}
};
const result = await this.upsertSubscriber(subscriberData);
return { success: true, subscriberId: result.id };
} catch (error) {
logger.error('Failed to sync campaign participant:', error.message);
return { success: false, error: error.message };
}
}
// Sync a custom recipient to Listmonk
async syncCustomRecipient(recipientData, campaignData) {
// Map NocoDB field names (column titles) to properties
const email = recipientData['Recipient Email'];
const name = recipientData['Recipient Name'];
const title = recipientData['Recipient Title'];
const organization = recipientData['Recipient Organization'];
const phone = recipientData['Recipient Phone'];
const createdAt = recipientData['CreatedAt'];
const campaignId = recipientData['Campaign ID'];
if (!this.syncEnabled || !email) {
return { success: false, error: 'Sync disabled or no email provided' };
}
// Validate and clean the email address
const emailValidation = this.validateAndCleanEmail(email);
if (!emailValidation.valid) {
logger.warn(`Skipping invalid recipient email: ${email} - ${emailValidation.error}`);
return { success: false, error: emailValidation.error };
}
try {
const subscriberLists = [this.lists.customRecipients.id];
// Add to campaign-specific list
const campaignSlug = campaignData?.['Campaign Slug'];
const campaignTitle = campaignData?.['Campaign Title'];
if (campaignSlug && campaignTitle) {
const campaignList = await this.ensureCampaignList(campaignSlug, campaignTitle);
if (campaignList) {
subscriberLists.push(campaignList.id);
logger.info(`📧 Added recipient ${emailValidation.cleaned} to campaign list: ${campaignTitle}`);
}
}
const subscriberData = {
email: emailValidation.cleaned,
name: name || emailValidation.cleaned,
status: 'enabled',
lists: subscriberLists,
attribs: {
campaign: campaignTitle || 'Unknown',
campaign_slug: campaignSlug || null,
title: title || null,
organization: organization || null,
phone: phone || null,
added_date: createdAt ? new Date(createdAt).toISOString() : null,
recipient_type: 'custom'
}
};
const result = await this.upsertSubscriber(subscriberData);
return { success: true, subscriberId: result.id };
} catch (error) {
logger.error('Failed to sync custom recipient:', error.message);
return { success: false, error: error.message };
}
}
// Sync an email log entry to Listmonk (generic public emails)
async syncEmailLog(emailData) {
// Map NocoDB field names for the Email Logs table
const senderEmail = emailData['Sender Email'];
const senderName = emailData['Sender Name'];
const recipientEmail = emailData['Recipient Email'];
const postalCode = emailData['Postal Code'];
const createdAt = emailData['CreatedAt'];
const sentAt = emailData['Sent At'];
if (!this.syncEnabled || !senderEmail) {
return { success: false, error: 'Sync disabled or no email provided' };
}
// Validate and clean the email address
const emailValidation = this.validateAndCleanEmail(senderEmail);
if (!emailValidation.valid) {
logger.warn(`Skipping invalid email log: ${senderEmail} - ${emailValidation.error}`);
return { success: false, error: emailValidation.error };
}
try {
const subscriberData = {
email: emailValidation.cleaned,
name: senderName || emailValidation.cleaned,
status: 'enabled',
lists: [this.lists.emailLogs.id],
attribs: {
last_sent: sentAt ? new Date(sentAt).toISOString() : (createdAt ? new Date(createdAt).toISOString() : new Date().toISOString()),
postal_code: postalCode || null,
last_recipient_email: recipientEmail || null,
source: 'email_logs'
}
};
const result = await this.upsertSubscriber(subscriberData);
return { success: true, subscriberId: result.id };
} catch (error) {
logger.error('Failed to sync email log:', error.message);
return { success: false, error: error.message };
}
}
// Upsert subscriber (create or update)
async upsertSubscriber(subscriberData) {
try {
const client = this.getClient();
// Try to find existing subscriber by email
const { data: searchResponse } = await client.get('/subscribers', {
params: { query: `subscribers.email = '${subscriberData.email}'` }
});
if (searchResponse.data.results && searchResponse.data.results.length > 0) {
// Update existing subscriber
const existingSubscriber = searchResponse.data.results[0];
const subscriberId = existingSubscriber.id;
// Merge lists (don't remove existing ones)
const existingLists = existingSubscriber.lists.map(l => l.id);
const newLists = [...new Set([...existingLists, ...subscriberData.lists])];
// Merge attributes
const mergedAttribs = {
...existingSubscriber.attribs,
...subscriberData.attribs
};
const updateData = {
...subscriberData,
lists: newLists,
attribs: mergedAttribs
};
const { data: updateResponse } = await client.put(`/subscribers/${subscriberId}`, updateData);
logger.info(`Updated subscriber: ${subscriberData.email}`);
return updateResponse.data;
} else {
// Create new subscriber
const { data: createResponse } = await client.post('/subscribers', subscriberData);
logger.info(`Created new subscriber: ${subscriberData.email}`);
return createResponse.data;
}
} catch (error) {
logger.error(`Failed to upsert subscriber ${subscriberData.email}:`, error.message);
throw error;
}
}
// Bulk sync campaign participants
async bulkSyncCampaignParticipants(emails, campaigns) {
if (!this.syncEnabled) {
return { total: 0, success: 0, failed: 0, errors: [] };
}
const results = {
total: emails.length,
success: 0,
failed: 0,
errors: []
};
// Create a map of campaign IDs to campaign data for quick lookup
const campaignMap = {};
if (campaigns && Array.isArray(campaigns)) {
console.log(`🔍 Building campaign map from ${campaigns.length} campaigns`);
campaigns.forEach((campaign, index) => {
// Show keys for first campaign to debug
if (index === 0) {
console.log('🔍 First campaign keys:', Object.keys(campaign));
}
// NocoDB returns 'ID' (all caps) as the system field for record ID
// This is what the 'Campaign ID' link field in emails table references
const id = campaign.ID || campaign.Id || campaign.id;
if (id) {
campaignMap[id] = campaign;
// Also map by slug for fallback lookup
const slug = campaign['Campaign Slug'];
if (slug) {
campaignMap[slug] = campaign;
}
const title = campaign['Campaign Title'];
console.log(`🔍 Mapped campaign ID ${id} and slug ${slug}: ${title}`);
} else {
console.log('⚠️ Campaign has no ID field! Keys:', Object.keys(campaign));
}
});
console.log(`🔍 Campaign map has ${Object.keys(campaignMap).length} entries`);
} else {
console.log('⚠️ No campaigns provided for mapping!');
}
for (const email of emails) {
try {
// Try to find campaign data by Campaign ID (link field) or Campaign Slug (text field)
const campaignId = email['Campaign ID'];
const campaignSlug = email['Campaign Slug'];
const campaignData = campaignId ? campaignMap[campaignId] : (campaignSlug ? campaignMap[campaignSlug] : null);
// Debug first email
if (emails.indexOf(email) === 0) {
console.log('🔍 First email keys:', Object.keys(email));
console.log('🔍 First email Campaign ID field:', email['Campaign ID']);
console.log('🔍 First email Campaign Slug field:', email['Campaign Slug']);
}
if (!campaignData && (campaignId || campaignSlug)) {
console.log(`⚠️ Campaign not found - ID: ${campaignId}, Slug: ${campaignSlug}. Available IDs:`, Object.keys(campaignMap).slice(0, 10));
}
const result = await this.syncCampaignParticipant(email, campaignData);
if (result.success) {
results.success++;
} else {
results.failed++;
const emailAddr = email['Sender Email'] || email.sender_email || 'unknown';
results.errors.push({
email: emailAddr,
error: result.error
});
}
} catch (error) {
results.failed++;
const emailAddr = email['Sender Email'] || email.sender_email || 'unknown';
results.errors.push({
email: emailAddr,
error: error.message
});
}
}
logger.info(`Bulk sync completed: ${results.success} succeeded, ${results.failed} failed`);
return results;
}
// Bulk sync custom recipients
async bulkSyncCustomRecipients(recipients, campaigns) {
if (!this.syncEnabled) {
return { total: 0, success: 0, failed: 0, errors: [] };
}
const results = {
total: recipients.length,
success: 0,
failed: 0,
errors: []
};
// Create a map of campaign IDs to campaign data for quick lookup
const campaignMap = {};
if (campaigns && Array.isArray(campaigns)) {
campaigns.forEach(campaign => {
// NocoDB returns 'ID' (all caps) as the system field for record ID
const id = campaign.ID || campaign.Id || campaign.id;
if (id) {
campaignMap[id] = campaign;
// Also map by slug for fallback lookup
const slug = campaign['Campaign Slug'];
if (slug) {
campaignMap[slug] = campaign;
}
}
});
}
for (const recipient of recipients) {
try {
// Try to find campaign data by Campaign ID or Campaign Slug
const campaignId = recipient['Campaign ID'];
const campaignSlug = recipient['Campaign Slug'];
const campaignData = campaignId ? campaignMap[campaignId] : (campaignSlug ? campaignMap[campaignSlug] : null);
const result = await this.syncCustomRecipient(recipient, campaignData);
if (result.success) {
results.success++;
} else {
results.failed++;
const emailAddr = recipient['Recipient Email'] || 'unknown';
results.errors.push({
email: emailAddr,
error: result.error
});
}
} catch (error) {
results.failed++;
const emailAddr = recipient['Recipient Email'] || 'unknown';
results.errors.push({
email: emailAddr,
error: error.message
});
}
}
logger.info(`Bulk sync custom recipients completed: ${results.success} succeeded, ${results.failed} failed`);
return results;
}
// Bulk sync email logs
async bulkSyncEmailLogs(emailLogs) {
if (!this.syncEnabled) {
return { total: 0, success: 0, failed: 0, errors: [] };
}
const results = {
total: emailLogs.length,
success: 0,
failed: 0,
errors: []
};
for (const emailLog of emailLogs) {
try {
const result = await this.syncEmailLog(emailLog);
if (result.success) {
results.success++;
} else {
results.failed++;
const emailAddr = emailLog['Sender Email'] || 'unknown';
results.errors.push({
email: emailAddr,
error: result.error
});
}
} catch (error) {
results.failed++;
const emailAddr = emailLog['Sender Email'] || 'unknown';
results.errors.push({
email: emailAddr,
error: error.message
});
}
}
logger.info(`Bulk sync email logs completed: ${results.success} succeeded, ${results.failed} failed`);
return results;
}
// Get list statistics
async getListStats() {
if (!this.syncEnabled) {
return null;
}
try {
const client = this.getClient();
const { data: listsResponse } = await client.get('/lists');
const stats = {};
const influenceLists = listsResponse.data.results.filter(list =>
list.tags && list.tags.includes('influence')
);
for (const list of influenceLists) {
stats[list.name] = {
name: list.name,
subscriber_count: list.subscriber_count || 0,
id: list.id
};
}
return stats;
} catch (error) {
logger.error('Failed to get list stats:', error.message);
return null;
}
}
// Get sync status
getSyncStatus() {
return {
enabled: this.syncEnabled,
connected: this.lastError === null,
lastError: this.lastError,
lastErrorTime: this.lastErrorTime,
listsInitialized: Object.values(this.lists).every(list => list !== null)
};
}
}
// Create singleton instance
const listmonkService = new ListmonkService();
// Initialize lists on startup if enabled
if (listmonkService.syncEnabled) {
listmonkService.initializeLists()
.then(async () => {
logger.info('✅ Listmonk service initialized successfully');
// Optional initial sync (only if explicitly enabled)
if (process.env.LISTMONK_INITIAL_SYNC === 'true') {
logger.info('🔄 Performing initial Listmonk sync for influence system...');
// Use setTimeout to delay initial sync to let app fully start
setTimeout(async () => {
try {
const nocodbService = require('./nocodb');
// Sync existing campaign participants
try {
// Use campaignEmails table (not emails) to get proper User Email/Name fields
const emailsData = await nocodbService.getAll(nocodbService.tableIds.campaignEmails);
const emails = emailsData?.list || [];
console.log('🔍 Initial sync - fetched campaign emails:', emails?.length || 0);
if (emails && emails.length > 0) {
console.log('🔍 First email full data:', JSON.stringify(emails[0], null, 2));
}
if (emails && emails.length > 0) {
const campaigns = await nocodbService.getAllCampaigns();
console.log('🔍 Campaigns fetched:', campaigns?.length || 0);
if (campaigns && campaigns.length > 0) {
console.log('🔍 First campaign full data:', JSON.stringify(campaigns[0], null, 2));
}
const emailResults = await listmonkService.bulkSyncCampaignParticipants(emails, campaigns);
logger.info(`📧 Initial campaign participants sync: ${emailResults.success} succeeded, ${emailResults.failed} failed`);
} else {
logger.warn('No campaign participants found for initial sync');
}
} catch (emailError) {
logger.warn('Initial campaign participants sync failed:', {
message: emailError.message,
stack: emailError.stack
});
}
// Sync existing custom recipients
try {
const recipientsData = await nocodbService.getAll(nocodbService.tableIds.customRecipients);
const recipients = recipientsData?.list || [];
console.log('🔍 Initial sync - fetched custom recipients:', recipients?.length || 0);
if (recipients && recipients.length > 0) {
const campaigns = await nocodbService.getAllCampaigns();
const recipientResults = await listmonkService.bulkSyncCustomRecipients(recipients, campaigns);
logger.info(`📋 Initial custom recipients sync: ${recipientResults.success} succeeded, ${recipientResults.failed} failed`);
} else {
logger.warn('No custom recipients found for initial sync');
}
} catch (recipientError) {
logger.warn('Initial custom recipients sync failed:', {
message: recipientError.message,
stack: recipientError.stack
});
}
logger.info('✅ Initial Listmonk sync completed');
} catch (error) {
logger.error('Initial Listmonk sync failed:', {
message: error.message,
stack: error.stack
});
}
}, 5000); // Wait 5 seconds for app to fully start
}
})
.catch(error => {
logger.error('Failed to initialize Listmonk service:', error.message);
});
}
module.exports = listmonkService;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,152 @@
const QRCode = require('qrcode');
const axios = require('axios');
const FormData = require('form-data');
/**
* QR Code Generation Service
* Generates QR codes for campaign and response wall URLs
*/
/**
* Generate QR code as PNG buffer
* @param {string} text - Text/URL to encode
* @param {Object} options - QR code options
* @returns {Promise<Buffer>} PNG buffer
*/
async function generateQRCode(text, options = {}) {
const defaultOptions = {
type: 'png',
width: 256,
margin: 1,
color: {
dark: '#000000',
light: '#FFFFFF'
},
errorCorrectionLevel: 'M'
};
const qrOptions = { ...defaultOptions, ...options };
try {
const buffer = await QRCode.toBuffer(text, qrOptions);
return buffer;
} catch (error) {
console.error('Failed to generate QR code:', error);
throw new Error('Failed to generate QR code');
}
}
/**
* Upload QR code to NocoDB storage
* @param {Buffer} buffer - PNG buffer
* @param {string} filename - Filename for the upload
* @param {Object} config - NocoDB configuration
* @returns {Promise<Object>} Upload response
*/
async function uploadQRCodeToNocoDB(buffer, filename, config) {
const formData = new FormData();
formData.append('file', buffer, {
filename: filename,
contentType: 'image/png'
});
try {
// Use the base URL without /api/v1 for v2 endpoints
const baseUrl = config.apiUrl.replace('/api/v1', '');
const uploadUrl = `${baseUrl}/api/v2/storage/upload`;
console.log(`Uploading QR code to: ${uploadUrl}`);
const response = await axios({
url: uploadUrl,
method: 'post',
data: formData,
headers: {
...formData.getHeaders(),
'xc-token': config.apiToken
},
params: {
path: 'qrcodes'
}
});
console.log('QR code upload successful:', response.data);
return response.data;
} catch (error) {
console.error('Failed to upload QR code to NocoDB:', error.response?.data || error.message);
throw new Error('Failed to upload QR code');
}
}
/**
* Generate and upload QR code
* @param {string} url - URL to encode
* @param {string} label - Label for the QR code
* @param {Object} config - NocoDB configuration
* @returns {Promise<Object>} Upload result
*/
async function generateAndUploadQRCode(url, label, config) {
if (!url) {
return null;
}
try {
// Generate QR code
const buffer = await generateQRCode(url);
// Create filename
const timestamp = Date.now();
const safeLabel = label.replace(/[^a-z0-9]/gi, '_').toLowerCase();
const filename = `qr_${safeLabel}_${timestamp}.png`;
// Upload to NocoDB
const uploadResult = await uploadQRCodeToNocoDB(buffer, filename, config);
return uploadResult;
} catch (error) {
console.error('Failed to generate and upload QR code:', error);
throw error;
}
}
/**
* Delete QR code from NocoDB storage
* @param {string} fileUrl - File URL to delete
* @param {Object} config - NocoDB configuration
* @returns {Promise<boolean>} Success status
*/
async function deleteQRCodeFromNocoDB(fileUrl, config) {
if (!fileUrl) {
return true;
}
try {
// Extract file path from URL
const urlParts = fileUrl.split('/');
const filePath = urlParts.slice(-2).join('/');
await axios({
url: `${config.apiUrl}/api/v2/storage/upload`,
method: 'delete',
headers: {
'xc-token': config.apiToken
},
params: {
path: filePath
}
});
return true;
} catch (error) {
console.error('Failed to delete QR code from NocoDB:', error);
// Don't throw error for deletion failures
return false;
}
}
module.exports = {
generateQRCode,
uploadQRCodeToNocoDB,
generateAndUploadQRCode,
deleteQRCodeFromNocoDB
};

View File

@ -0,0 +1,146 @@
const axios = require('axios');
class RepresentAPIService {
constructor() {
this.baseURL = process.env.REPRESENT_API_BASE || 'https://represent.opennorth.ca';
this.rateLimit = parseInt(process.env.REPRESENT_API_RATE_LIMIT) || 60;
this.lastRequestTime = 0;
this.requestCount = 0;
this.resetTime = Date.now() + 60000; // Reset every minute
}
async checkRateLimit() {
const now = Date.now();
// Reset counter if a minute has passed
if (now > this.resetTime) {
this.requestCount = 0;
this.resetTime = now + 60000;
}
// Check if we're at the rate limit
if (this.requestCount >= this.rateLimit) {
const waitTime = this.resetTime - now;
throw new Error(`Rate limit exceeded. Please wait ${Math.ceil(waitTime / 1000)} seconds.`);
}
this.requestCount++;
this.lastRequestTime = now;
}
async makeRequest(endpoint) {
await this.checkRateLimit();
try {
const response = await axios.get(`${this.baseURL}${endpoint}`, {
timeout: 10000,
headers: {
'User-Agent': 'Alberta-Influence-Campaign-Tool/1.0'
}
});
return response.data;
} catch (error) {
if (error.response) {
throw new Error(`API Error: ${error.response.status} - ${error.response.statusText}`);
} else if (error.request) {
throw new Error('Network error: Unable to reach Represent API');
} else {
throw new Error(`Request error: ${error.message}`);
}
}
}
async testConnection() {
try {
const data = await this.makeRequest('/boundary-sets/?limit=1');
return {
success: true,
message: 'Successfully connected to Represent API',
sampleData: data
};
} catch (error) {
return {
success: false,
message: 'Failed to connect to Represent API',
error: error.message
};
}
}
async getRepresentativesByPostalCode(postalCode) {
const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
// Validate Alberta postal code (should start with T)
if (!formattedPostalCode.startsWith('T')) {
throw new Error('This tool is designed for Alberta postal codes only (starting with T)');
}
try {
const endpoint = `/postcodes/${formattedPostalCode}/`;
console.log(`Making Represent API request to: ${this.baseURL}${endpoint}`);
const data = await this.makeRequest(endpoint);
console.log('Represent API Response:', JSON.stringify(data, null, 2));
console.log(`Representatives concordance count: ${data.representatives_concordance?.length || 0}`);
console.log(`Representatives centroid count: ${data.representatives_centroid?.length || 0}`);
return {
postalCode: formattedPostalCode,
city: data.city,
province: data.province,
centroid: data.centroid,
representatives_concordance: data.representatives_concordance || [],
representatives_centroid: data.representatives_centroid || [],
boundaries_concordance: data.boundaries_concordance || [],
boundaries_centroid: data.boundaries_centroid || []
};
} catch (error) {
console.error(`Represent API error for ${formattedPostalCode}:`, error.message);
throw new Error(`Failed to fetch data for postal code ${formattedPostalCode}: ${error.message}`);
}
}
async getRepresentativeDetails(representativeUrl) {
try {
// Extract the path from the URL
const urlPath = representativeUrl.replace(this.baseURL, '');
const data = await this.makeRequest(urlPath);
return data;
} catch (error) {
throw new Error(`Failed to fetch representative details: ${error.message}`);
}
}
async getBoundaryDetails(boundaryUrl) {
try {
// Extract the path from the URL
const urlPath = boundaryUrl.replace(this.baseURL, '');
const data = await this.makeRequest(urlPath);
return data;
} catch (error) {
throw new Error(`Failed to fetch boundary details: ${error.message}`);
}
}
async searchRepresentatives(filters = {}) {
try {
const queryParams = new URLSearchParams();
// Add filters as query parameters
Object.keys(filters).forEach(key => {
if (filters[key]) {
queryParams.append(key, filters[key]);
}
});
const endpoint = `/representatives/?${queryParams.toString()}`;
const data = await this.makeRequest(endpoint);
return data;
} catch (error) {
throw new Error(`Failed to search representatives: ${error.message}`);
}
}
}
module.exports = new RepresentAPIService();

View File

@ -0,0 +1,132 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{CAMPAIGN_TITLE}} - Campaign Message</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #e74c3c;
padding-bottom: 20px;
}
.logo {
color: #e74c3c;
font-size: 24px;
font-weight: bold;
}
.campaign-badge {
background: linear-gradient(135deg, #e74c3c, #c0392b);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
display: inline-block;
margin-top: 10px;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #e74c3c;
}
.message-body {
font-size: 16px;
line-height: 1.7;
margin: 20px 0;
color: #2c3e50;
}
.sender-info {
background-color: #fff5f5;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
border: 1px solid #fecaca;
}
.info-item {
margin: 8px 0;
display: flex;
align-items: center;
}
.info-label {
font-weight: bold;
display: inline-block;
width: 120px;
color: #7f1d1d;
}
.info-value {
color: #2c3e50;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
.app-branding {
color: #e74c3c;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{APP_NAME}}</div>
<div class="campaign-badge">{{CAMPAIGN_TITLE}}</div>
<p style="margin: 10px 0 0 0; color: #6c757d;">Constituent Advocacy Campaign</p>
</div>
<div class="content">
<div class="message-body">
{{MESSAGE}}
</div>
<div class="sender-info">
<h4 style="margin: 0 0 10px 0; color: #7f1d1d;">Campaign Participant Information:</h4>
<div class="info-item">
<span class="info-label">Name:</span>
<span class="info-value">{{USER_NAME}}</span>
</div>
<div class="info-item">
<span class="info-label">Email:</span>
<span class="info-value">{{USER_EMAIL}}</span>
</div>
<div class="info-item">
<span class="info-label">Postal Code:</span>
<span class="info-value">{{POSTAL_CODE}}</span>
</div>
{{#if RECIPIENT_NAME}}
<div class="info-item">
<span class="info-label">To:</span>
<span class="info-value">{{RECIPIENT_NAME}} ({{RECIPIENT_LEVEL}})</span>
</div>
{{/if}}
</div>
</div>
<div class="footer">
<p>This message was sent via the <span class="app-branding">{{APP_NAME}}</span> as part of the "<strong>{{CAMPAIGN_TITLE}}</strong>" campaign at {{TIMESTAMP}}</p>
<p>This platform enables constituents to participate in organized advocacy campaigns to communicate with their elected representatives.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,16 @@
{{CAMPAIGN_TITLE}} - Campaign Message
{{MESSAGE}}
---
Campaign Participant Information:
Name: {{USER_NAME}}
Email: {{USER_EMAIL}}
Postal Code: {{POSTAL_CODE}}
{{#if RECIPIENT_NAME}}
To: {{RECIPIENT_NAME}} ({{RECIPIENT_LEVEL}})
{{/if}}
---
This message was sent via the {{APP_NAME}} as part of the "{{CAMPAIGN_TITLE}}" campaign at {{TIMESTAMP}}
This platform enables constituents to participate in organized advocacy campaigns to communicate with their elected representatives.

View File

@ -0,0 +1,113 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Verify Your Email to Create a Campaign</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
color: #d73027;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 20px;
border-radius: 6px;
margin-bottom: 20px;
}
.cta-button {
display: inline-block;
background-color: #d73027;
color: white !important;
padding: 14px 28px;
text-decoration: none;
border-radius: 4px;
margin: 20px 0;
font-weight: bold;
text-align: center;
}
.footer {
text-align: center;
font-size: 12px;
color: #666;
margin-top: 30px;
}
.info {
color: #3498db;
font-size: 14px;
margin-top: 20px;
padding: 15px;
background-color: #e8f4f8;
border-radius: 4px;
}
.highlight {
background-color: #fff3cd;
padding: 15px;
border-radius: 4px;
margin: 15px 0;
border-left: 4px solid #ffc107;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{APP_NAME}}</div>
</div>
<div class="content">
<h2>🚀 Verify Your Email to Create a Campaign</h2>
<p>Hi {{USER_NAME}},</p>
<p>You're one step away from turning your message into a powerful campaign!</p>
<div class="highlight">
<strong>What happens next?</strong>
<ul style="margin: 10px 0;">
<li>Click the verification button below</li>
<li>Create your account (if you don't have one)</li>
<li>Your email content will be pre-filled</li>
<li>Add campaign details and publish!</li>
</ul>
</div>
<p style="text-align: center;">
<a href="{{VERIFICATION_URL}}" class="cta-button">Verify Email & Create Campaign</a>
</p>
<div class="info">
<strong>⏰ Important:</strong> This link expires in 24 hours.
</div>
<p style="margin-top: 20px; font-size: 14px; color: #666;">
If you didn't request this, you can safely ignore this email.
</p>
<p style="margin-top: 20px; font-size: 12px; color: #999;">
If the button doesn't work, copy and paste this link into your browser:<br>
<span style="word-break: break-all;">{{VERIFICATION_URL}}</span>
</p>
</div>
<div class="footer">
<p>{{APP_NAME}} - Empowering civic engagement</p>
<p style="margin-top: 10px;">{{TIMESTAMP}}</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,24 @@
{{APP_NAME}}
Verify Your Email to Create a Campaign
Hi {{USER_NAME}},
You're one step away from turning your message into a powerful campaign!
What happens next?
- Click the verification link below
- Create your account (if you don't have one)
- Your email content will be pre-filled
- Add campaign details and publish!
Verify your email by clicking this link:
{{VERIFICATION_URL}}
⏰ Important: This link expires in 24 hours.
If you didn't request this, you can safely ignore this email.
---
{{APP_NAME}} - Empowering civic engagement
{{TIMESTAMP}}

View File

@ -0,0 +1,116 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Your Login Details</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
color: #d73027;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 20px;
border-radius: 6px;
margin-bottom: 20px;
}
.credentials-box {
background-color: #f0f0f0;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
border: 1px solid #ddd;
}
.credential-item {
margin: 10px 0;
}
.credential-label {
font-weight: bold;
display: inline-block;
width: 100px;
}
.credential-value {
font-family: monospace;
font-size: 16px;
color: #2c3e50;
}
.login-button {
display: inline-block;
background-color: #d73027;
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 4px;
margin: 20px 0;
}
.footer {
text-align: center;
font-size: 12px;
color: #666;
margin-top: 30px;
}
.info {
color: #3498db;
font-size: 14px;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{APP_NAME}}</div>
</div>
<div class="content">
<h2>Your Login Details</h2>
<p>Hello {{USER_NAME}},</p>
<p>Here are your login credentials for {{APP_NAME}}:</p>
<div class="credentials-box">
<div class="credential-item">
<span class="credential-label">Email:</span>
<span class="credential-value">{{USER_EMAIL}}</span>
</div>
<div class="credential-item">
<span class="credential-label">Password:</span>
<span class="credential-value">{{PASSWORD}}</span>
</div>
<div class="credential-item">
<span class="credential-label">Role:</span>
<span class="credential-value">{{USER_ROLE}}</span>
</div>
</div>
<p>You can log in using the link below:</p>
<p style="text-align: center;">
<a href="{{LOGIN_URL}}" class="login-button">Login to {{APP_NAME}}</a>
</p>
<p class="info">💡 For security reasons, we recommend changing your password after your first login.</p>
</div>
<div class="footer">
<p>This email was sent from {{APP_NAME}} at {{TIMESTAMP}}</p>
<p>If you have any questions, please contact your administrator.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,17 @@
Login Details - {{APP_NAME}}
Hello {{USER_NAME}},
Here are your login credentials for {{APP_NAME}}:
Email: {{USER_EMAIL}}
Password: {{PASSWORD}}
Role: {{USER_ROLE}}
You can log in at: {{LOGIN_URL}}
For security reasons, we recommend changing your password after your first login.
---
This email was sent from {{APP_NAME}} at {{TIMESTAMP}}
If you have any questions, please contact your administrator.

View File

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Message from Constituent</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #3498db;
padding-bottom: 20px;
}
.logo {
color: #3498db;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #3498db;
}
.message-body {
font-size: 16px;
line-height: 1.7;
margin: 20px 0;
color: #2c3e50;
}
.sender-info {
background-color: #f8f9fa;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
border: 1px solid #e9ecef;
}
.info-item {
margin: 8px 0;
display: flex;
align-items: center;
}
.info-label {
font-weight: bold;
display: inline-block;
width: 120px;
color: #495057;
}
.info-value {
color: #2c3e50;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
.app-branding {
color: #3498db;
font-weight: bold;
}
</style>
</head>
<body>
<div class="content">
<div class="message-body">
<p>Dear {{RECIPIENT_NAME}},</p>
{{MESSAGE}}
<p>Sincerely,<br>
{{SENDER_NAME}}<br>
{{POSTAL_CODE}}</p>
</div>
<div class="sender-info">
<h4 style="margin: 0 0 10px 0; color: #495057;">Constituent Information:</h4>
<div class="info-item">
<span class="info-label">Name:</span>
<span class="info-value">{{SENDER_NAME}}</span>
</div>
<div class="info-item">
<span class="info-label">Email:</span>
<span class="info-value">{{SENDER_EMAIL}}</span>
</div>
<div class="info-item">
<span class="info-label">Postal Code:</span>
<span class="info-value">{{POSTAL_CODE}}</span>
</div>
</div>
</div>
<div class="footer">
<p>This message was sent via the <span class="app-branding">{{APP_NAME}}</span> at {{TIMESTAMP}}</p>
<p>This platform enables constituents to communicate directly with their elected representatives.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,17 @@
Dear {{RECIPIENT_NAME}},
{{MESSAGE}}
Sincerely,
{{SENDER_NAME}}
{{POSTAL_CODE}}
---
Constituent Information:
Name: {{SENDER_NAME}}
Email: {{SENDER_EMAIL}}
Postal Code: {{POSTAL_CODE}}
---
This message was sent via the {{APP_NAME}} at {{TIMESTAMP}}
This platform enables constituents to communicate directly with their elected representatives.

View File

@ -0,0 +1,155 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify Response Submission</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
padding-bottom: 20px;
border-bottom: 2px solid #3498db;
margin-bottom: 30px;
}
.header h1 {
color: #2c3e50;
margin: 0;
font-size: 24px;
}
.content {
margin-bottom: 30px;
}
.info-box {
background-color: #f8f9fa;
border-left: 4px solid #3498db;
padding: 15px;
margin: 20px 0;
}
.info-box strong {
display: block;
color: #2c3e50;
margin-bottom: 5px;
}
.button-container {
text-align: center;
margin: 30px 0;
}
.button {
display: inline-block;
padding: 12px 30px;
margin: 10px;
text-decoration: none;
border-radius: 5px;
font-weight: bold;
font-size: 16px;
}
.verify-button {
background-color: #27ae60;
color: #ffffff;
}
.verify-button:hover {
background-color: #229954;
}
.report-button {
background-color: #e74c3c;
color: #ffffff;
}
.report-button:hover {
background-color: #c0392b;
}
.response-preview {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
white-space: pre-wrap;
font-size: 14px;
}
.footer {
text-align: center;
padding-top: 20px;
border-top: 1px solid #dee2e6;
margin-top: 30px;
font-size: 12px;
color: #7f8c8d;
}
.warning {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
color: #856404;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📧 Response Verification Request</h1>
</div>
<div class="content">
<p>Dear {{REPRESENTATIVE_NAME}},</p>
<p>A constituent has submitted a response they claim to have received from you through the <strong>{{APP_NAME}}</strong> platform.</p>
<div class="info-box">
<strong>Campaign:</strong> {{CAMPAIGN_TITLE}}
<br>
<strong>Response Type:</strong> {{RESPONSE_TYPE}}
<br>
<strong>Submitted:</strong> {{SUBMITTED_DATE}}
<br>
<strong>Submitted By:</strong> {{SUBMITTER_NAME}}
</div>
<div class="response-preview">
<strong>Response Content:</strong><br>
{{RESPONSE_TEXT}}
</div>
<div class="warning">
<strong>⚠️ Action Required</strong><br>
Please verify whether this response is authentic by clicking one of the buttons below.
</div>
<div class="button-container">
<a href="{{VERIFICATION_URL}}" class="button verify-button">
✓ Verify This Response
</a>
<a href="{{REPORT_URL}}" class="button report-button">
✗ Report as Invalid
</a>
</div>
<p><strong>Why verify?</strong> Verification helps maintain transparency and accountability in constituent communications. Verified responses appear with a special badge on the Response Wall.</p>
<p><strong>What happens if I report?</strong> Reported responses will be marked as disputed and may be hidden from public view while we investigate.</p>
</div>
<div class="footer">
<p>This email was sent by {{APP_NAME}}<br>
You received this because a constituent submitted a response attributed to you.<br>
Verification links expire in 30 days.</p>
<p><strong>Timestamp:</strong> {{TIMESTAMP}}</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,42 @@
RESPONSE VERIFICATION REQUEST
==============================
Dear {{REPRESENTATIVE_NAME}},
A constituent has submitted a response they claim to have received from you through the {{APP_NAME}} platform.
SUBMISSION DETAILS:
-------------------
Campaign: {{CAMPAIGN_TITLE}}
Response Type: {{RESPONSE_TYPE}}
Submitted: {{SUBMITTED_DATE}}
Submitted By: {{SUBMITTER_NAME}}
RESPONSE CONTENT:
-----------------
{{RESPONSE_TEXT}}
ACTION REQUIRED:
---------------
Please verify whether this response is authentic by clicking one of the links below.
VERIFY THIS RESPONSE:
{{VERIFICATION_URL}}
REPORT AS INVALID:
{{REPORT_URL}}
WHY VERIFY?
-----------
Verification helps maintain transparency and accountability in constituent communications. Verified responses appear with a special badge on the Response Wall.
WHAT HAPPENS IF I REPORT?
--------------------------
Reported responses will be marked as disputed and may be hidden from public view while we investigate.
---
This email was sent by {{APP_NAME}}
You received this because a constituent submitted a response attributed to you.
Verification links expire in 30 days.
Timestamp: {{TIMESTAMP}}

View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Test Email - {{APP_NAME}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #fff3cd;
border-radius: 8px;
padding: 30px;
border: 2px solid #ffc107;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #ffc107;
padding-bottom: 20px;
}
.logo {
color: #856404;
font-size: 24px;
font-weight: bold;
}
.test-badge {
background: #dc3545;
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
display: inline-block;
margin-top: 10px;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #ffc107;
}
.message-body {
font-size: 16px;
line-height: 1.7;
margin: 20px 0;
color: #2c3e50;
}
.test-info {
background-color: #f8d7da;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
border: 1px solid #f5c6cb;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
.app-branding {
color: #856404;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{APP_NAME}}</div>
<div class="test-badge">TEST EMAIL</div>
<p style="margin: 10px 0 0 0; color: #856404;">Email System Test</p>
</div>
<div class="content">
<div class="test-info">
<h4 style="margin: 0 0 10px 0; color: #721c24;">⚠️ This is a test email</h4>
<p style="margin: 0; color: #721c24;">This email was sent to verify the email system is working correctly.</p>
</div>
<div class="message-body">
{{MESSAGE}}
</div>
</div>
<div class="footer">
<p><strong>TEST EMAIL</strong> sent from <span class="app-branding">{{APP_NAME}}</span> at {{TIMESTAMP}}</p>
<p>If you received this email, the email system is functioning properly.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,10 @@
TEST EMAIL - {{APP_NAME}}
⚠️ This is a test email
This email was sent to verify the email system is working correctly.
{{MESSAGE}}
---
TEST EMAIL sent from {{APP_NAME}} at {{TIMESTAMP}}
If you received this email, the email system is functioning properly.

View File

@ -0,0 +1,109 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{EMAIL_SUBJECT}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
color: #d73027;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 20px;
border-radius: 6px;
margin-bottom: 20px;
}
.message-content {
line-height: 1.6;
}
.message-content h1,
.message-content h2,
.message-content h3 {
color: #2c3e50;
margin-top: 25px;
margin-bottom: 15px;
}
.message-content h1 {
font-size: 24px;
border-bottom: 2px solid #e9ecef;
padding-bottom: 10px;
}
.message-content h2 {
font-size: 20px;
}
.message-content h3 {
font-size: 18px;
}
.message-content ul,
.message-content ol {
margin: 15px 0;
padding-left: 25px;
}
.message-content li {
margin: 8px 0;
}
.message-content a {
color: #d73027;
text-decoration: none;
}
.message-content a:hover {
text-decoration: underline;
}
.message-content strong {
font-weight: 600;
color: #2c3e50;
}
.message-content blockquote {
border-left: 4px solid #d73027;
margin: 20px 0;
padding: 10px 20px;
background-color: #f8f9fa;
font-style: italic;
}
.footer {
text-align: center;
font-size: 12px;
color: #666;
margin-top: 30px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{APP_NAME}}</div>
</div>
<div class="content">
<p>Hello {{USER_NAME}},</p>
<div class="message-content">
{{EMAIL_CONTENT}}
</div>
</div>
<div class="footer">
<p>This email was sent from {{APP_NAME}} at {{TIMESTAMP}}</p>
<p>{{SENDER_NAME}} - System Administrator</p>
</div>
</div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More