Full Code of VA3HDL/hamdashboard for AI

main c9f9b6c92e1e cached
9 files
120.0 KB
31.4k tokens
8 symbols
1 requests
Download .txt
Repository: VA3HDL/hamdashboard
Branch: main
Commit: c9f9b6c92e1e
Files: 9
Total size: 120.0 KB

Directory structure:
gitextract_72fn_dc0/

├── .gitignore
├── LICENSE
├── README.md
├── config.js
├── config.json
├── config_jsonp.js
├── hamdash.html
├── satellite.js
└── wheelzoom.js

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitignore
================================================
.vscode
*sync*


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2024 Pablo Sabbag, VA3HDL

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# Ham Dashboard (hamdashboard)

Live demo: [Hamdash Demo](https://va3hdl.github.io/hamdash/)

This repository provides a simple, browser-based ham radio dashboard that displays images, maps, web pages, and feeds in a configurable grid. It is lightweight, easy to host, and suitable for use on a local computer, Raspberry Pi, or a static host such as GitHub Pages or Cloudflare Pages.

Quick demo videos:
- Original presentation: [YouTube - VA3HDL presentation](https://www.youtube.com/watch?v=sIdqMQTGNSc)
- Spanish overview: [YouTube - VA3HDL en español](https://www.youtube.com/watch?v=IBMxELofKVA)

## User-submitted public dashboards
These live dashboards were shared by members of the ham community:

- [BCAT N4TDX](https://qsl.net/n/n5ng/BCAT/) — Steve N5NG (Brevard County ARES)
  - Steve's config files (as .txt): <https://qsl.net/n5ng/config.txt> and <https://qsl.net/n5ng/HAM/config.txt>
- [FFX DEMS](https://kq4dne.github.io/WeatherDash/WeatherDash.html) — Sandy KQ4DNE
- [FFX ARES](https://kq4dne.github.io/hamdash/hamdash.html) — Sandy KQ4DNE
- [WA4MED](https://dashboard.wa4med.us/hamdash.html) — Matthew WA4MED
- [PY3TX](https://dashboard.py3tx.com/) — South America
- [VE7CAS](https://hamradio.smecher.bc.ca/) — Vancouver, BC
- [G0IKV](https://g0ikv.qsy.to/) — Southport, England
- [OK1SLM](https://www.qsl.net/ok1slm/) — Prague
- [VK3VSN](https://www.vicscan.com/hamdash/) — Melbourne, Australia
- [K6BCW](https://elihickox.com/radio/hamdashboard/hamdash.html) — San Francisco Bay Area
- [KN6PTQ](https://kn6ptq.com/) — San Francisco Bay Area
- [W2SZ](https://dashboard.w2sz.org/) — NE US
- [N2YQT](https://dashboard.tourge.net/) — NE US
- [KC2VWR](https://baef57ae.ham-desktop.pages.dev/) — NE US
- [KD2YFY](https://dash.kd2yfy.net/) — NE US
- [KD4VRD](https://hamdashboard-8fn.pages.dev/) — North Carolina
- [KD5PQJ](https://kd5pqj.com/dash/index.html) — Texas
- [N5GAH](http://n5gah.com/) — Texas
- [KJ7YYI](https://kj7yyi.net/ham-dash/) — Arizona
- [NQ0M](https://hamdash.nq0m.com/#) — Kansas
- [W3RDW](https://dashboard.w3rdw.radio/) — Ohio
- [W4QAL](https://w4qal.net/dashboard/index.html) — West Florida

## Quick start

1. Download the following files from this repository into a single folder: `hamdash.html`, `config.js`, and `wheelzoom.js`.
2. Open `hamdash.html` in your browser.
3. Use the right-side menu and select "Setup" to open the settings UI and configure your dashboard.
4. Alternatively, edit `config.js` in a text editor to set sources, menus, and layout.
5. Load configuration from the browser (Local Storage) or from `config.js`, then save your settings.

**Notes**
- For hosted (server) installations, store settings in `config.js` so the server serves the same configuration to all visitors.
- For personal use or testing, Local Storage keeps changes specific to your browser session.
- Now is possible to use a pure Json file format for the configuration load on hosted environments
- For file:// access (non-hosted usage) a newer JsonP-style format is available for the configuration load

## Settings UI

The settings UI provides buttons to manage configurations and backups:

<img src="https://github.com/VA3HDL/hamdashboard/blob/main/examples/settings_buttons.png?raw=true" width="800">

- Save Settings to Local Storage — Save current page settings in the browser.
- Reset to Defaults — Restore sample settings for testing.
- Backup Settings to JSON file — Download a JSON file with your settings.
- Restore Settings from JSON file — Load settings from a JSON backup.
- Import from `config.js` — Load settings defined in a `config.js` file (recommended for servers).
- Export to `config.js` — Export current settings in `config.js` format for hosting.

## Public dashboards and safety

The "Setup" UI cannot modify the server-side `config.js` file. When a visitor switches a public dashboard to Local Storage, the change affects only that visitor's browser. To hide the **Setup** option or **Load Cfg** option on public installations, add the following lines to your `config.js`:

```
const disableSetup = true;
const disableLdCfg = true;
```

## Video guides

- [Configuration instructions — Jason KM4ACK](https://youtu.be/9ZZXg60tN-o)
- [Raspberry Pi setup — Andreas M0FXB](https://www.youtube.com/watch?v=Km_vOCvCMFM)
- [Live stream — Frank KG6NLW](https://www.youtube.com/watch?v=rJHCpNHDbC0&t=140s)
- [Live stream — KM9G](https://www.youtube.com/watch?v=ohlHaSsf6B8=400s)
- [Ham Dashboard on Inovato Quadra — Peter KJ5AJB](https://www.youtube.com/watch?v=u07Oz-YSrQY)
- [French review — Jean-Benard F5SVP](https://www.youtube.com/watch?v=o9Dl9A5hqQI)
- [Spanish instructions — Jose EA8EE](https://www.youtube.com/watch?v=3CnsfB3zNuM)

## Getting help

Always check the [Q&A section](https://github.com/VA3HDL/hamdashboard/discussions/categories/q-a) for solutions to common issues.

## Docker

Michael Stevens maintains a Docker image: [michaelsteven/hamdashboard](https://registry.hub.docker.com/r/michaelsteven/hamdashboard)

## How to use

- Double-click an image to view full-screen; double-click again to close.
- Right-click an image to cycle to the next image (if multiple images are assigned to a tile).
- Tiles refresh independently (default refresh behavior: every 5 minutes for most sources).
- Tiles with iFrames: double click to unlock the tile and interact with the content

## Pi-Star iFrame embedding (fix)

If a remote site sets the `X-Frame-Options` header it may prevent embedding via iframes. On Pi-Star you can temporarily switch to read/write, edit the nginx security config, and restart nginx:

```bash
rpi-rw
sudo nano /etc/nginx/default.d/security.conf
# comment out: add_header X-Frame-Options  "SAMEORIGIN";

sudo systemctl restart nginx.service
```

This screenshot shows Pi-Star settings:

<img src="https://github.com/VA3HDL/hamdashboard/blob/main/examples/pistar.png?raw=true" width="400">

## iFrame tips

If the source server forbids embedding and you cannot change its headers, options are limited. A local proxy that strips the header can work but adds complexity. Use the online tool to test a URL before adding it to a tile: [iFrame Tester online](https://iframetester.com/)

## Changelog highlights (most recent)

See the chronological entries below for full details. Notable recent changes:

- 2026.01.30 — Fixed RSS feeds not loading on some hosting situations due to a CORS issue.

- 2026.01.24 — Added 10 features:
  1. JSON & JSONP Configuration Support (example .json and JsonP .js files added to the repo)
  2. Dynamic Date Placeholders
  3. Rotating Tile **Titles** - Requested by multiple users, see example in all 3 config files
  4. Smart Mixed-Media Interactivity (for tiles mixing images, videos, iFrames)
  5. Enhanced Full-Screen Navigation
  6. Setup UI Improvements
  7. Enhanced Breadcrumb Navigation to provide always a return path to previous configs
  8. PREVIOUS Menu Button
  9. Enhanced Config File Detection to support various file formats
  10. File Picker Integration to load different dashboards on the fly

- 2026.01.22 — Added directives to load images and iframes with colors inverted. Full details on the release notes.
- 2026.01.17 — Ability to load any config files via the menu.
- 2025.11.12 — Switch between multiple config files (e.g., `satellite.js`) via the menu.
- 2025.04.02 — RSS feed refresh times configurable; feed ticker added.
- 2025.03.29 — Scrolling RSS ticker and clickable feed items.
- 2025.01.24 — Settings merged into `hamdash.html`; realtime variable changes enabled.

## Upgrade notes

- For simpler sintax you can now use Json or JsonP files for config files
- Read the specific upgrade notes in the changelog below before replacing `config.js`
- To use multiple config files, add a menu entry in `config.js` such as:

```
var aURL = [  
  ["f3de21ff", "SATS", "satellite.js"],
  ["f3de21ff", "WX", "weather.js", "1", "R"]
];
```
**Rotating Tile Titles Usage:**

Pass an array as the first element of a tile configuration.
```javascript
// Example in config.js
[
  ["Radar CONUS", "Radar Local"], 
  "https://radar.com/map1.gif", 
  "https://radar.com/map2.gif"
]
```
## Example images

<img src="https://github.com/VA3HDL/hamdashboard/blob/main/examples/satellite.png?raw=true" width="600">
<img src="https://github.com/VA3HDL/hamdashboard/blob/main/examples/config.png?raw=true" width="600">

Grid examples

<img src="https://github.com/VA3HDL/hamdashboard/blob/main/examples/2x2.png?raw=true" width="200">
<img src="https://github.com/VA3HDL/hamdashboard/blob/main/examples/3x3.png?raw=true" width="200">
<img src="https://github.com/VA3HDL/hamdashboard/blob/main/examples/4x4.png?raw=true" width="200">
<img src="https://github.com/VA3HDL/hamdashboard/blob/main/examples/5x3.png?raw=true" width="200">

## More notes and history

The repository includes a detailed changelog documenting fixes, features, and upgrade instructions dating back through 2024. Please review the changelog entries below before performing upgrades.

[Releases & Change logs](https://github.com/VA3HDL/hamdashboard/releases)

## Host with Cloudflare Pages (free)

Tutorial contributed by Robert W3RDW:
[How to host your dashboard with Cloudflare Pages, free](https://w3rdw.radio/posts/hamdashboard/)

## Sample dashboards submitted by users

![VA3HDL Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/dashboard_sample.png?raw=true)

![N4NBC Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/N4NBC-sample.jpg?raw=true)

![KM4ACK Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/KM4ACK-sample.png?raw=true)

![TI3GB Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/TI3GB-sample.png?raw=true)

![N5NG Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/N5NG-sample.png?raw=true)

![VK3MLT Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/VK3MLT-sample.png?raw=true)

![VK5TUX Sample Dashboard](examples/VK5TUX_Sample_VA3HDL_Ham_Radio_Dashboard.png?raw=true)

![VK5TUX Sample Dashboard Sources](examples/VK5TUX_Sample_VA3HDL_Ham_Radio_Dashboard_Sources.png?raw=true)

![N4TDX Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/N4TDX-sample.png?raw=true)

![WG5EEK Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/WG5EEK-sample.jpg?raw=true)

![KJ5FMX Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/KJ5FMX-sample.jpg?raw=true)

![N0RMJ Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/N0RMJ-sample.jpg?raw=true)

![N5GAH Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/N5GAH-sample.jpg?raw=true)

![OES MarTech Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/OESmartech.jpg?raw=true)

![TheSky Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/TheSky.jpg?raw=true)

![KJ7T Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/KJ7T-sample.png?raw=true)

![K4HNH Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/K4HNH-sample.jpg?raw=true)

![CT1ETE Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/CT1ETE-sample.jpg?raw=true)

![VK3FS Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/VK3FS-sample.png?raw=true)

![W5EAK Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/W5EAK-sample.jpg?raw=true)

![WI5L Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/WI5L-sample.jpg?raw=true)

![WX9WTF Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/WX9WTF-sample.jpg?raw=true)

### Dual menu example

![Dual side Menu Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/DualMenu.png?raw=true)

### Sources display example

![Sources display example](https://github.com/VA3HDL/hamdashboard/blob/main/examples/sources.png?raw=true)


================================================
FILE: config.js
================================================
const disableSetup = false;
const disableLdCfg = false;
var topBarCenterText = `VA3HDL - FN04ga - .js`;

// Grid layout
var layout_cols = 4;
var layout_rows = 3;

// Menu items
// Structure is as follows HTML Color code, Option, target URL, scaling 1=Original Size, side (optional, nothing is Left, "R" is Right)
// The values are [color code, menu text, target link, scale factor, side],
// add new lines following the structure for extra menu options. The comma at the end is important!
var aURL = [
  ["f3de21", "SATS", "satellite.js"],
  
  ["2196F3", "CLUBLOG", "https://clublog.org/livestream/VA3HDL", "1.7"],
  [
    "2196F3",
    "CONTEST",
    "https://www.contestcalendar.com/fivewkcal.html",
    "1",
  ],
  ["2196F3", "DX CLUSTER", "https://dxcluster.ha8tks.hu/map/", "1"],
  [
    "2196F3",
    "LIGHTNING",
    "https://map.blitzortung.org/#3.87/36.5/-89.41",
    "1",
    "R",
  ],
  ["2196F3", "PISTAR", "http://pi-star.local/", "1.2"],
  [
    "2196F3",
    "RADAR",
    "dark|https://weather.gc.ca/?layers=alert,radar&center=43.39961001,-78.53212031&zoom=6&alertTableFilterProv=ON",
    "1",
    "R"
  ],
  ["2196F3", "TIME.IS", "https://time.is/", "1", "R"],
  [
    "2196F3",
    "WEATHER",
    "https://openweathermap.org/weathermap?basemap=map&cities=true&layer=temperature&lat=44.0157&lon=-79.4591&zoom=5",
    "1",
    "R",
  ],
  [
    "2196F3",
    "WINDS",
    "https://earth.nullschool.net/#current/wind/surface/level/orthographic=-78.79,44.09,3000",
    "1",
    "R",
  ],
];

// Dashboard items
// Structure is Title, Image Source URL
// [Title, Image Source URL],
// the comma at the end is important!
// You can't add more items because there are only 12 placeholders on the dashboard
// but you can replace the titles and the images with anything you want.
var aIMG = [
  [["Radar CONUS", "Radar Small"], "https://radar.weather.gov/ridge/standard/CONUS-LARGE_loop.gif", "https://radar.weather.gov/ridge/standard/CONUS_loop.gif"],
  [
    "LOCAL RADAR (inverted)",
    "invert|https://radar.weather.gov/ridge/standard/KNQA_loop.gif",
  ],
  [
    "NOAA D-RAP (inverted)",
    "invert|https://s.w-x.co/staticmaps/wu/wxtype/county_loc/bgm/animate.png",
  ],
  [
    "ISS POSITION",
    "https://www.heavens-above.com/orbitdisplay.aspx?icon=iss&width=600&height=300&mode=M&satid=25544",
  ],
  [
    "SATELLITE CAN",
    "https://cdn.star.nesdis.noaa.gov/GOES16/GLM/SECTOR/can/EXTENT3/GOES16-CAN-EXTENT3-1125x560.gif",
  ],
  [
    "SATELLITE CGL",
    "https://cdn.star.nesdis.noaa.gov/GOES16/GLM/SECTOR/cgl/EXTENT3/GOES16-CGL-EXTENT3-600x600.gif",
  ],
  [
    "LIGHTNING",
    "https://images.lightningmaps.org/blitzortung/america/index.php?animation=usa",
  ],
  [
    "LIGHTNING LOCAL",
    "https://www.blitzortung.org/en/Images/image_b_ny.png",
  ],
  ["YOUTUBE EXAMPLE", "iframe|https://www.youtube.com/embed/fzPFaXAV_2Y?autoplay=1&mute=1"],
  [
    "WEBSITE EXAMPLE",
    "iframe|https://globe.adsbexchange.com/?airport=YYZ",
  ],
  ["VIDEO EXAMPLE", "https://himawari8.nict.go.jp/movie/720/20240611_pifd.mp4"],
  ["HF PROPAGATION",
    "https://www.hamqsl.com/solar101vhf.php"],
];

// Image rotation intervals in milliseconds per tile - If the line below is commented, all tiles will be rotated every 30000 milliseconds (30s)
var tileDelay = [
  11200,10000,11000,10100,
  10200,10500,10300,10600,
  30400,60700,60900,10800
];

// RSS feed items
// Structure is [feed URL, refresh interval in minutes]
var aRSS = [
  ["https://www.amsat.org/feed/", 60],           // Example RSS feed, refresh every 60 minutes
  ["https://daily.hamweekly.com/atom.xml", 120], // Example Atom feed, refresh every 120 minutes
  ];


================================================
FILE: config.json
================================================
{
  "disableSetup": false,
  "disableLdCfg": false,
  "topBarCenterText": "VA3HDL - FN04ga - JSON",
  "layout_cols": 4,
  "layout_rows": 3,
  "aURL": [
    ["f3de21", "SATS", "satellite.js"],
    ["2196F3", "CLUBLOG", "https://clublog.org/livestream/VA3HDL", "1.7"],
    ["2196F3", "CONTEST", "https://www.contestcalendar.com/fivewkcal.html", "1"],
    ["2196F3", "DX CLUSTER", "https://dxcluster.ha8tks.hu/map/", "1"],
    ["2196F3", "LIGHTNING", "https://map.blitzortung.org/#3.87/36.5/-89.41", "1", "R"],
    ["2196F3", "PISTAR", "http://pi-star.local/", "1.2"],
    ["2196F3", "RADAR", "dark|https://weather.gc.ca/?layers=alert,radar&center=43.39961001,-78.53212031&zoom=6&alertTableFilterProv=ON", "1", "R"],
    ["2196F3", "TIME.IS", "https://time.is/", "1", "R"],
    ["2196F3", "WEATHER", "https://openweathermap.org/weathermap?basemap=map&cities=true&layer=temperature&lat=44.0157&lon=-79.4591&zoom=5", "1", "R"],
    ["2196F3", "WINDS", "https://earth.nullschool.net/#current/wind/surface/level/orthographic=-78.79,44.09,3000", "1", "R"]
  ],
  "aIMG": [
    [["Radar CONUS", "Radar Small"], ["https://radar.weather.gov/ridge/standard/CONUS-LARGE_loop.gif", "https://radar.weather.gov/ridge/standard/CONUS_loop.gif"], 60100],
    ["LOCAL RADAR (inverted)", "invert|https://radar.weather.gov/ridge/standard/KNQA_loop.gif", 10000],
    ["NOAA D-RAP (inverted)", "invert|https://s.w-x.co/staticmaps/wu/wxtype/county_loc/bgm/animate.png", 11000],
    ["ISS POSITION", "https://www.heavens-above.com/orbitdisplay.aspx?icon=iss&width=600&height=300&mode=M&satid=25544", 10100],
    ["SATELLITE CAN", "https://cdn.star.nesdis.noaa.gov/GOES16/GLM/SECTOR/can/EXTENT3/GOES16-CAN-EXTENT3-1125x560.gif", 10200],
    ["SATELLITE CGL", "https://cdn.star.nesdis.noaa.gov/GOES16/GLM/SECTOR/cgl/EXTENT3/GOES16-CGL-EXTENT3-600x600.gif", 10500],
    ["LIGHTNING", "https://images.lightningmaps.org/blitzortung/america/index.php?animation=usa", 10300],
    ["LIGHTNING LOCAL", "https://www.blitzortung.org/en/Images/image_b_ny.png", 10600],
    ["YOUTUBE EXAMPLE", "iframe|https://www.youtube.com/embed/fzPFaXAV_2Y?autoplay=1&mute=1", 30400],
    ["WEBSITE EXAMPLE", "iframe|https://globe.adsbexchange.com/?airport=YYZ", 60700],
    ["VIDEO EXAMPLE", "https://himawari8.nict.go.jp/movie/720/20240611_pifd.mp4", 60900],
    ["HF PROPAGATION", "https://www.hamqsl.com/solar101vhf.php", 10800]
  ],  
  "aRSS": [
    ["https://www.amsat.org/feed/", 60],
    ["https://daily.hamweekly.com/atom.xml", 120]
  ]
}


================================================
FILE: config_jsonp.js
================================================
window.hamdashConfig = {
  "disableSetup": false,
  "disableLdCfg": false,
  "topBarCenterText": "VA3HDL-FN04ga-JsonP",
  "layout_cols": 4,
  "layout_rows": 3,
  "aURL": [
    ["f3de21", "SATS", "satellite.js"],
    ["2196F3", "CLUBLOG", "https://clublog.org/livestream/VA3HDL", "1.7"],
    ["2196F3", "CONTEST", "https://www.contestcalendar.com/fivewkcal.html", "1"],
    ["2196F3", "DX CLUSTER", "https://dxcluster.ha8tks.hu/map/", "1"],
    ["2196F3", "LIGHTNING", "https://map.blitzortung.org/#3.87/36.5/-89.41", "1", "R"],
    ["2196F3", "PISTAR", "http://pi-star.local/", "1.2"],
    ["2196F3", "RADAR", "dark|https://weather.gc.ca/?layers=alert,radar&center=43.39961001,-78.53212031&zoom=6&alertTableFilterProv=ON", "1", "R"],
    ["2196F3", "TIME.IS", "https://time.is/", "1", "R"],
    ["2196F3", "WEATHER", "https://openweathermap.org/weathermap?basemap=map&cities=true&layer=temperature&lat=44.0157&lon=-79.4591&zoom=5", "1", "R"],
    ["2196F3", "WINDS", "https://earth.nullschool.net/#current/wind/surface/level/orthographic=-78.79,44.09,3000", "1", "R"]
  ],
  "aIMG": [
    [["Radar CONUS", "Radar Small"], ["https://radar.weather.gov/ridge/standard/CONUS-LARGE_loop.gif", "https://radar.weather.gov/ridge/standard/CONUS_loop.gif"], 60100],
    ["LOCAL RADAR (inverted)", "invert|https://radar.weather.gov/ridge/standard/KNQA_loop.gif", 10000],
    ["NOAA D-RAP (inverted)", "invert|https://s.w-x.co/staticmaps/wu/wxtype/county_loc/bgm/animate.png", 11000],
    ["ISS POSITION", "https://www.heavens-above.com/orbitdisplay.aspx?icon=iss&width=600&height=300&mode=M&satid=25544", 10100],
    ["SATELLITE CAN", "https://cdn.star.nesdis.noaa.gov/GOES16/GLM/SECTOR/can/EXTENT3/GOES16-CAN-EXTENT3-1125x560.gif", 10200],
    ["SATELLITE CGL", "https://cdn.star.nesdis.noaa.gov/GOES16/GLM/SECTOR/cgl/EXTENT3/GOES16-CGL-EXTENT3-600x600.gif", 10500],
    ["LIGHTNING", "https://images.lightningmaps.org/blitzortung/america/index.php?animation=usa", 10300],
    ["LIGHTNING LOCAL", "https://www.blitzortung.org/en/Images/image_b_ny.png", 10600],
    ["YOUTUBE EXAMPLE", "iframe|https://www.youtube.com/embed/fzPFaXAV_2Y?autoplay=1&mute=1", 30400],
    ["WEBSITE EXAMPLE", "iframe|https://globe.adsbexchange.com/?airport=YYZ", 60700],
    ["VIDEO EXAMPLE", "https://himawari8.nict.go.jp/movie/720/20240611_pifd.mp4", 60900],
    ["HF PROPAGATION", "https://www.hamqsl.com/solar101vhf.php", 10800]
  ],  
  "aRSS": [
    ["https://www.amsat.org/feed/", 60],
    ["https://daily.hamweekly.com/atom.xml", 120]
  ]
};


================================================
FILE: hamdash.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
  <meta http-equiv="Pragma" content="no-cache" />
  <meta http-equiv="Expires" content="0" />
  <meta http-equiv="Cache" content="no-cache" />
  <meta http-equiv="Pragma-Control" content="no-cache" />
  <meta http-equiv="Cache-directive" content="no-cache" />
  <meta http-equiv="Pragma-directive" content="no-cache" />
  <meta http-equiv="Cache-Control" content="no-cache" />
  <meta http-equiv="Pragma-directive: no-cache" />
  <meta http-equiv="Cache-directive: no-cache" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta name="description" content="VA3HDL Ham Radio Dashboard" />
  <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📡</text></svg>">
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link href="https://fonts.googleapis.com/css?family=Victor Mono|Audiowide|Bebas Neue" rel="stylesheet" />
  <link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@100..900&display=swap" rel="stylesheet" />
  <title>Ham Radio Dashboard</title>
  <!--
	Hamdash
	License: MIT
  Copyright (c) 2026 Pablo Sabbag, VA3HDL
	https://www.va3hdl.com/projects/hamdash

  Credits:
  Project inspired by the concept of DAVID A GOLD callsign N2MXX published at https://nject.us/HAMSHACK-DASHBOARD-O.html
--

.d8888. d888888b db    db db      d88888b .d8888.
88'  YP `~~88~~' `8b  d8' 88      88'     88'  YP
`8bo.      88     `8bd8'  88      88ooooo `8bo.
  `Y8b.    88       88    88      88~~~~~   `Y8b.
db   8D    88       88    88booo. 88.     db   8D
`8888Y'    YP       YP    Y88888P Y88888P `8888Y'


-->
  <style>
    body,
    html {
      background: black;
      font-size: 100%;
      max-width: 100%;
      overflow-x: hidden;
      height: 100%;
      margin: 0;
      justify-content: center;
      align-items: center;
    }

    /* Style for the div containing the iframe */
    .iframe-container {
      background-color: black;
      border: 0px none;
      position: fixed;
      height: 100%;
      width: 100%;
      z-index: -2;
      justify-content: center;
      align-items: center;
    }

    .img-zoom {
      background-color: black;
      left: 0px;
      border: 0px none;
      height: 100%;
      width: 100%;
      position: fixed;
      overflow: hidden;
      bottom: 0px;
      z-index: -2;
    }

    /* Style for the iframe element */
    .full-screen {
      background-color: black;
      border: 0px none;
      height: 100%;
      width: 98%;
      margin-bottom: 0px;
      margin-left: 1%;
      justify-content: center;
      align-items: center;
      -ms-zoom: 1;
      -moz-transform: scale(1);
      -moz-transform-origin: 0 0;
      -o-transform: scale(1);
      -o-transform-origin: 0 0;
      -webkit-transform: scale(1);
      -webkit-transform-origin: 0 0;
    }

    .default-frame {
      margin-top: 0px;
      margin-bottom: 0px;
      margin-left: 0px;
      left: 0px;
      border: 0px none;
      height: 100%;
      width: 100%;
      position: fixed;
      overflow: hidden;
      bottom: 0px;
    }

    .top-bar {
      display: grid;
      grid-template-columns: 2fr 1fr 2fr;
      background-color: #333;
      color: #fff;
      padding: 1vh;
      border: 0px none;
      overflow: hidden;
      position: relative;
      width: auto;
    }

    .child {
      position: relative;
      display: grid;
      border: 1px solid hsl(210deg 8% 50%);
      border-radius: 5px;
      background: hsl(210deg 15% 20%);
      color: white;
      padding: 0.5vh;
      font-family: "Victor Mono", sans-serif;
      font-size: 1.4vw;
    }

    /* Default variables values for grid layout 4 cols x 3 rows */
    :root {
      --main-layout: auto auto auto auto;
      --main-width: 24.9vw;
      --main-height: 31vh;
    }

    /* Style for the dashboard container */
    .dashboard {
      display: grid;
      grid-template-columns: var(--main-layout);
      grid-gap: 0px;
      border: 0px none;
      margin-bottom: 0px;
      overflow: hidden;
      position: relative;
      width: 100%;
    }

    /* Style for the image container */
    .image-container {
      position: relative;
      float: inline-start;
      margin-right: 0px;
      border: 0px none;
      height: var(--main-height);
      width: var(--main-width);
      overflow: hidden;
      display: flex;
      justify-content: center;
      /* Horizontal centering */
      align-items: center;
      /* Vertical centering */
      border-radius: 5px;
      /* Rounded corners */
    }

    .iframe-tile {
      position: absolute;
      /* it must be absolute */
      float: inline-start;
      overflow: hidden;
      border: 0px none;
      display: flex;
      height: var(--main-height);
      width: var(--main-width);
      margin: 0;
      border-radius: 5px;
      /* Rounded corners */
    }

    /* Style for the image */
    .image-container img {
      height: 100%;
      width: 100%;
    }

    /* Style for the image titles */
    .image-title {
      position: absolute;
      bottom: 6%;
      left: 50%;
      transform: translate(-50%, -50%);
      color: white;
      /* font color */
      background-color: black;
      font-size: 1vw;
      border-left: 0.25vw solid black;
      border-right: 0.25vw solid black;
      font-family: "Roboto Condensed", sans-serif;
      font-optical-sizing: auto;
      font-weight: 300;
      font-style: normal;
      padding-top: 1px;
      z-index: 2;
    }

    .click-overlay {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-color: transparent;
      z-index: 1;
      cursor: pointer;
    }

    /* Style for the full screen image */
    .image-large {
      display: block;
      position: relative;
      margin-left: auto;
      margin-right: auto;
      max-height: 100%;
      max-width: 100%;
      height: 100%;
      width: auto;
    }

    .media {
      height: 100%;
      width: 100%;
      cursor: pointer;
    }

    .hidden {
      display: none;
    }

    /* Style for the left menu options */
    .menuL {
      display: grid;
      grid-gap: 3px;
      position: absolute;
      height: auto;
      width: auto;
      margin-top: 10vh;
      left: calc(-5.2vw - 0px);
      z-index: 2;
      transition: 0.3s;
    }

    /* Style for the right menu options */
    .menuR {
      display: grid;
      grid-gap: 3px;
      position: absolute;
      height: auto;
      width: 30px;
      margin-top: 10vh;
      right: -5px;
      z-index: 2;
      transition: 0.3s;
    }

    #myMenuL:hover {
      width: 7vw;
      left: 0px;
    }

    #myMenuR:hover {
      width: 7vw;
      right: 0px;
    }

    #mySidenavL a {
      position: relative;
      float: inline-start;
      left: calc(-0.2vw - 10px);
      transition: 0.3s;
      padding-left: 15px;
      padding-right: 15px;
      padding-top: 12px;
      padding-bottom: 8px;
      width: 5vw;
      text-decoration: none;
      font-family: "Bebas Neue", sans-serif;
      font-size: 1.2vw;
      font-optical-sizing: auto;
      font-weight: 300;
      font-style: normal;
      text-align: right;
      color: white;
      border-radius: 0 5px 5px 0;
      box-shadow: 4px 4px 12px rgba(0, 0, 0, 0.5);
    }

    #mySidenavL a:hover {
      left: 0;
    }

    #mySidenavR a {
      position: relative;
      float: inline-start;
      right: calc(-0.2vw - 10px);
      transition: 0.3s;
      padding-left: 15px;
      padding-right: 15px;
      padding-top: 12px;
      padding-bottom: 8px;
      width: 7vw;
      text-decoration: none;
      font-family: "Bebas Neue", sans-serif;
      font-size: 1.2vw;
      font-optical-sizing: auto;
      font-weight: 300;
      font-style: normal;
      text-align: left;
      color: white;
      border-radius: 5px 0px 0px 5px;
      box-shadow: 4px 4px 12px rgba(0, 0, 0, 0.5);
    }

    #mySidenavR a:hover {
      right: 0;
      width: 7vw;
    }

    .menu-link.menu-core::before {
      content: '★';
      margin-right: 0px;
      margin-left: 0px;
      opacity: 0.8;
      /*
        position: absolute;
        top: 1px;
        left: 1px;
        */
    }

    .menu-link.menu-config::before {
      content: '⚙️';
      margin-right: 0px;
      margin-left: 0px;
      opacity: 0.8;
      display: inline-block;
      transform: scale(0.7);
      transform-origin: center;
      /*
        position: absolute;
        top: 1px;
        left: 1px; 
        */
    }

    .menu-link.menu-user::before {
      content: '';
      margin-right: 1px;
    }

    .overlay {
      display: none;
      position: fixed;
      top: 0;
      left: 0;
      height: 100%;
      width: 100%;
      background-color: rgba(0, 0, 0, 0.8);
      color: white;
      padding: 50px;
      box-sizing: border-box;
      z-index: 3;
      font-family: "Roboto Condensed", sans-serif;
    }

    .overlay-content {
      background-color: #333;
      padding: 20px;
      border-radius: 10px;
      max-height: 80vh;
      /* Ensures the overlay content is scrollable if it exceeds the viewport height */
      overflow-y: auto;
    }

    .close-btn {
      cursor: pointer;
      color: white;
      float: right;
      font-size: 20px;
    }

    /************************/
    /* Settings page styles */
    /************************/
    .settings-Page {
      display: none;
      font-family: Arial, sans-serif;
      margin-top: 37px;
      background-color: aliceblue;
      height: 98%;
      width: 98%;
      margin-left: 1%;
      overflow-y: auto;
    }

    .fixed-section {
      display: none;
      text-align: center;
      position: fixed;
      top: 0;
      left: 1%;
      width: 98%;
      background-color: cadetblue;
      padding-top: 7px;
      box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
      z-index: -3;
    }

    .section {
      margin-bottom: 20px;
      display: flex;
      flex-wrap: wrap;
    }

    label {
      margin: 2px;
      font-weight: bold;
    }

    input[type="text"] {
      width: 90%;
      padding: 1px;
      margin-right: 5px;
      margin-bottom: 3px;
    }

    input[type="number"] {
      width: 20%;
      padding: 1px;
      margin-bottom: 10px;
    }

    table {
      width: 100%;
      border-collapse: collapse;
      margin-bottom: 10px;
    }

    table th,
    table td {
      border: 1px solid #ddd;
      padding: 8px;
      align-content: start;
    }

    table th {
      background-color: #f4f4f4;
    }

    button {
      padding: 5px 5px;
      margin: 0 0 7px 0;
      cursor: pointer;
      background-image: linear-gradient(#f7f8fa, #e7e9ec);
      border-color: #adb1b8 #a2a6ac #8d9096;
      border-style: solid;
      border-width: 1px;
      border-radius: 3px;
      box-shadow: rgba(255, 255, 255, 0.6) 0 1px 0 inset;
      box-sizing: border-box;
      color: #0f1111;
      cursor: pointer;
      font-family: "Amazon Ember", Arial, sans-serif;
      font-size: 14px;
      height: 23px;
      font-size: 13px;
      outline: 0;
      overflow: hidden;
      padding: 0 11px;
      text-align: center;
      text-decoration: none;
      text-overflow: ellipsis;
      user-select: none;
      -webkit-user-select: none;
      touch-action: manipulation;
      white-space: nowrap;
    }

    button:active {
      border-bottom-color: #a2a6ac;
    }

    button:active:hover {
      border-bottom-color: #a2a6ac;
    }

    button:hover {
      border-color: #a2a6ac #979aa1 #82858a;
    }

    button:focus {
      border-color: #e77600;
      box-shadow: rgba(228, 121, 17, 0.5) 0 0 3px 2px;
      outline: 0;
    }

    .radio-group {
      display: flex;
      align-items: center;
    }

    .radio-group label {
      margin-right: 15px;
      padding-top: 5px;
      font-weight: normal;
    }

    /************************/
    /* Settings RSS styles  */
    /************************/
    .rss-ticker {
      position: fixed;
      bottom: 0;
      width: 100%;
      background-color: rgba(0, 0, 0, 0.5);
      overflow: hidden;
      white-space: nowrap;
      box-sizing: border-box;
      padding: 3px 0;
      font-family: "Victor Mono", sans-serif;
      font-size: 1.4vh;
      font-weight: bold;
    }

    .rss-ticker-content {
      display: inline-block;
      padding-left: 100%;
      animation: ticker var(--ticker-duration, 90s) linear infinite;
      animation-play-state: running;
      /* Default state is running */
    }

    .rss-ticker-content a {
      text-decoration: none;
      color: #e77600;
    }

    @keyframes ticker {
      0% {
        transform: translateX(0%);
      }

      100% {
        transform: translateX(-100%);
      }
    }
  </style>

  <!--
.d8888.  .o88b. d8888b. d888888b d8888b. d888888b .d8888.
88'  YP d8P  Y8 88  `8D   `88'   88  `8D `~~88~~' 88'  YP
`8bo.   8P      88oobY'    88    88oodD'    88    `8bo.
  `Y8b. 8b      88`8b      88    88~~~      88      `Y8b.
db   8D Y8b  d8 88 `88.   .88.   88         88    db   8D
`8888Y'  `Y88P' 88   YD Y888888P 88         YP    `8888Y'
-->
  <script src="wheelzoom.js"></script>
  <script>
    // Track critical page events
    window.addEventListener('DOMContentLoaded', () => {
      console.log('►►► DOMContentLoaded fired');
    });

    window.addEventListener('load', () => {
      console.log('►►► Page fully loaded');
    });

    // Track script execution
    console.log('►►► Script loaded and running');

    //   .d8888. d88888b d888888b d888888b d888888b d8b   db  d888b  .d8888.      .d8888. d88888b  .o88b. d888888b d888888b  .d88b.  d8b   db
    //   88'  YP 88'     `~~88~~' `~~88~~'   `88'   888o  88 88' Y8b 88'  YP      88'  YP 88'     d8P  Y8 `~~88~~'   `88'   .8P  Y8. 888o  88
    //   `8bo.   88ooooo    88       88       88    88V8o 88 88      `8bo.        `8bo.   88ooooo 8P         88       88    88    88 88V8o 88
    //     `Y8b. 88~~~~~    88       88       88    88 V8o88 88  ooo   `Y8b.        `Y8b. 88~~~~~ 8b         88       88    88    88 88 V8o88
    //   db   8D 88.        88       88      .88.   88  V888 88. ~8~ db   8D      db   8D 88.     Y8b  d8    88      .88.   `8b  d8' 88  V888
    //   `8888Y' Y88888P    YP       YP    Y888888P VP   V8P  Y888P  `8888Y'      `8888Y' Y88888P  `Y88P'    YP    Y888888P  `Y88P'  VP   V8P

    function getColumnHeaderTitle(tableId, columnNumber) {
      const table = document.getElementById(tableId);
      if (!table) {
        console.error(`Table with id ${tableId} not found.`);
        return null;
      }

      const headers = table.querySelectorAll("thead th");
      if (columnNumber < 0 || columnNumber >= headers.length) {
        console.error(`Invalid column number ${columnNumber}.`);
        return null;
      }

      return headers[columnNumber].textContent;
    }

    document.addEventListener("DOMContentLoaded", () => {
      // Default Values
      const defaults = {
        settingsSource: "localStorage",
        topBarCenterText: "CALLSIGN - Locator",
        layout_cols: 4,
        layout_rows: 3,
        aURL: [["2196F3", "Photos", "https://picsum.photos/", 1, "L"]],
        aImages: [
          ["Tile 1", ["https://picsum.photos/seed/picsum/200/300"], 30000],
          ["Tile 2", ["https://picsum.photos/seed/picsum/200/300"], 30000],
          ["Tile 3", ["https://picsum.photos/seed/picsum/200/300"], 30000],
          ["Tile 4", ["https://picsum.photos/seed/picsum/200/300"], 30000],
          ["Tile 5", ["https://picsum.photos/seed/picsum/200/300"], 30000],
          ["Tile 6", ["https://picsum.photos/seed/picsum/200/300"], 30000],
          ["Tile 7", ["https://picsum.photos/seed/picsum/200/300"], 30000],
          ["Tile 8", ["https://picsum.photos/seed/picsum/200/300"], 30000],
          ["Tile 9", ["https://picsum.photos/seed/picsum/200/300"], 30000],
          ["Tile 10", ["https://picsum.photos/seed/picsum/200/300"], 30000],
          ["Tile 11", ["https://picsum.photos/seed/picsum/200/300"], 30000],
          ["Tile 12", ["https://picsum.photos/seed/picsum/200/300"], 30000],
        ],
        aRSS: [
          ["https://www.amsat.org/feed/", 60],           // Example RSS feed
          ["https://daily.hamweekly.com/atom.xml", 60], // Example Atom feed
        ],
      };

      // Load from LocalStorage or Defaults
      settings = JSON.parse(localStorage.getItem("hamdash_config")) || {
        ...defaults,
      };

      if (settings.settingsSource) {
        document.querySelector(
          `input[name="settingsSource"][value="${settings.settingsSource}"]`
        ).checked = true;
      }

      // Utility to Update Dashboard Items to Match Layout
      adjustDashboardItems = () => {
        const totalItems = settings.layout_cols * settings.layout_rows;
        const currentItems = settings.aImages.length;

        if (currentItems < totalItems) {
          // Add new placeholders if there are fewer items than needed
          for (let i = currentItems; i < totalItems; i++) {
            settings.aImages.push(["", [""], 5000]); // Default title, image array, and rotation interval
          }
        } else if (currentItems > totalItems) {
          // Remove excess items
          settings.aImages.splice(totalItems);
        }

        updateTable("dashboardTable", settings.aImages, [
          "Tile Title",
          "Tile URLs",
          "URL Rotation Interval (ms)",
        ]);
      };

      updateMenuTable = () => {
        updateTable("menuTable", settings.aURL, [
          "Color",
          "Text",
          "URL",
          "Scale",
          "Side",
        ]);
      };

      updateFeedTable = () => {
        updateTable("feedTable", settings.aRSS, [
          "Feed URL",
          "Refresh Interval (minutes)",
        ]);
      };

      // Utility to Update Tables
      const updateTable = (tableId, data, columns) => {
        const tbody = document.querySelector(`#${tableId} tbody`);
        tbody.innerHTML = "";
        data.forEach((item, index) => {
          const row = document.createElement("tr");
          columns.forEach((col, colIndex) => {
            const cell = document.createElement("td");
            if (tableId == "menuTable" && colIndex == 0) {                        // Color column for Menu options
              const colorInput = document.createElement("input");
              colorInput.type = "color";
              colorInput.value = "#" + item[colIndex].replace("#", "");
              colorInput.onchange = (e) => (item[colIndex] = e.target.value);
              cell.appendChild(colorInput);
            } else {
              if (Array.isArray(item[colIndex])) {
                // Handle array of image URLs                  
                const container = document.createElement("div");
                item[colIndex].forEach((url, urlIndex) => {
                  const textarea = document.createElement("input");
                  textarea.type =
                    getColumnHeaderTitle(tableId, colIndex) === ""
                      ? "number"
                      : "text";
                  textarea.value = url;
                  textarea.onchange = (e) =>
                    (item[colIndex][urlIndex] = e.target.value);
                  container.appendChild(textarea);
                  const removeBtn = document.createElement("button");
                  removeBtn.textContent = "Remove URL";
                  removeBtn.onclick = () => {
                    item[colIndex].splice(urlIndex, 1);
                    updateTable(tableId, data, columns);
                  };
                  container.appendChild(document.createElement("br"));
                  container.appendChild(removeBtn);
                  container.appendChild(document.createElement("br"));
                });
                const addBtn = document.createElement("button");
                addBtn.textContent = "Add URL";
                addBtn.onclick = () => {
                  item[colIndex].push("");
                  updateTable(tableId, data, columns);
                };
                container.appendChild(addBtn);
                cell.appendChild(container);
              } else {
                const input = document.createElement("input");
                switch (getColumnHeaderTitle(tableId, colIndex)) {
                  case "Scale":
                    input.type = "number";
                    break;
                  case "URL Rotation Interval (ms)":
                    input.type = "number";
                    break;
                  default:
                    input.type = "text";
                }
                input.value = item[colIndex];
                input.onchange = (e) =>
                (item[colIndex] =
                  colIndex === 2 && tableId === "dashboardTable"
                    ? parseInt(e.target.value, 10)
                    : e.target.value);

                // ADD CONVERT TO ARRAY BUTTON FOR TITLES
                if (tableId === "dashboardTable" && colIndex === 0 && !Array.isArray(item[colIndex])) {
                  const convertBtn = document.createElement("button");
                  convertBtn.textContent = "Convert to Array";
                  convertBtn.style.fontSize = "10px";
                  convertBtn.style.height = "18px";
                  convertBtn.onclick = () => {
                    item[colIndex] = [item[colIndex]];
                    updateTable(tableId, data, columns);
                  };
                  cell.appendChild(document.createElement("br"));
                  cell.appendChild(convertBtn);
                }

                cell.appendChild(input);
              }
            }
            row.appendChild(cell);
          });

          const actionsCell = document.createElement("td");
          const deleteBtn = document.createElement("button");
          deleteBtn.textContent = "Delete";
          deleteBtn.onclick = () => {
            data.splice(index, 1);
            updateTable(tableId, data, columns);
            adjustDashboardItems();
          };
          actionsCell.appendChild(deleteBtn);
          row.appendChild(actionsCell);

          tbody.appendChild(row);
        });
      };

      // Load Initial Data
      document.getElementById("CenterText").value = settings.topBarCenterText;
      document.getElementById("layout_cols").value = settings.layout_cols;
      document.getElementById("layout_rows").value = settings.layout_rows;
      updateMenuTable();
      updateFeedTable();
      adjustDashboardItems(); // Ensure dashboard items match layout on load

      // Save Configuration
      document.getElementById("saveConfig").onclick = () => {
        settings.settingsSource = document.querySelector(
          'input[name="settingsSource"]:checked'
        ).value;
        settings.topBarCenterText =
          document.getElementById("CenterText").value;
        settings.layout_cols = parseInt(
          document.getElementById("layout_cols").value,
          10
        );
        settings.layout_rows = parseInt(
          document.getElementById("layout_rows").value,
          10
        );

        // Update aURL from the table
        const menuTableRows = document.querySelectorAll("#menuTable tbody tr");
        settings.aURL = Array.from(menuTableRows).map(row => {
          const cells = row.querySelectorAll("td");
          return [
            cells[0].querySelector("input").value,
            cells[1].querySelector("input").value,
            cells[2].querySelector("input").value,
            parseInt(cells[3].querySelector("input").value, 10),
            cells[4].querySelector("input").value
          ];
        });

        // Update aRSS from the table
        const feedTableRows = document.querySelectorAll("#feedTable tbody tr");
        settings.aRSS = Array.from(feedTableRows).map(row => {
          const cells = row.querySelectorAll("td");
          return [
            cells[0].querySelector("input").value,              // Feed URL
            parseInt(cells[1].querySelector("input").value, 10) // Refresh Interval
          ];
        });

        localStorage.setItem("hamdash_config", JSON.stringify(settings));
        alert("Settings saved!");
        updateInputs();
        updateMenuTable();
        updateFeedTable();
        adjustDashboardItems();
      };

      // Reset to Defaults
      document.getElementById("resetConfig").onclick = () => {
        localStorage.setItem("hamdash_config", JSON.stringify(defaults));
        alert("Settings reset to defaults!");
        settings = defaults;
        updateInputs();
        updateMenuTable();
        updateFeedTable();
        adjustDashboardItems();
      };

      // Delete Configuration from local storage
      document.getElementById("deleteConfig").onclick = () => {
        window.localStorage.removeItem('hamdash_config');
        alert("Deleted local storage settings!");
        // settings = defaults;
        updateInputs();
        updateMenuTable();
        updateFeedTable();
        adjustDashboardItems();
      };

      // Backup Configuration
      document.getElementById("backupConfig").onclick = () => {
        const dataStr =
          "data:text/json;charset=utf-8," +
          encodeURIComponent(JSON.stringify(settings));
        const downloadAnchorNode = document.createElement("a");
        downloadAnchorNode.setAttribute("href", dataStr);
        downloadAnchorNode.setAttribute(
          "download",
          "hamdash_config_backup.json"
        );
        document.body.appendChild(downloadAnchorNode);
        downloadAnchorNode.click();
        downloadAnchorNode.remove();
      };

      // Restore Configuration
      document.getElementById("restoreConfig").onclick = () => {
        const input = document.createElement("input");
        input.type = "file";
        input.accept = "application/json";
        input.onchange = (event) => {
          const file = event.target.files[0];
          const reader = new FileReader();
          reader.onload = (e) => {
            settings = JSON.parse(e.target.result);
            alert("\nSettings restored from backup!\n\nRemember to Save Settings to Local Storage, Backup, or Export\n\nif you want to make changes permanent.");
            updateInputs();
            updateMenuTable();
            updateFeedTable();
            adjustDashboardItems();
          };
          reader.readAsText(file);
        };
        input.click();
      };

      // Import config.js
      document.getElementById("importConfig").onclick = () => {
        const input = document.createElement("input");
        input.type = "file";
        input.accept = ".js";
        input.onchange = (event) => {
          const file = event.target.files[0];
          const reader = new FileReader();
          reader.onload = (e) => {
            // Wrap the content in an IIFE to avoid polluting the global scope
            const configScript = `(function() {
                ${e.target.result}
                return {
                  topBarCenterText,
                  layout_cols,
                  layout_rows,
                  aURL,
                  aIMG,
                  aRSS,
                  tileDelay
                };
              })()`;

            // Evaluate the IIFE and get the variables
            const config = eval(configScript);

            // Filter out sub-arrays from aURL containing "BACK" or "Refresh"
            const filteredAURL = config.aURL.filter(
              (item) =>
                !item.some(
                  (subItem) =>
                    typeof subItem === "string" &&
                    (subItem.includes("BACK") ||
                      subItem.includes("Back") ||
                      subItem.includes("Refresh") ||
                      subItem.includes("Sources") ||
                      subItem.includes("Update") ||
                      subItem.includes("Help"))
                )
            );

            // Create a JSON structure from the variables
            const configJSON = {
              topBarCenterText: config.topBarCenterText,
              layout_cols: config.layout_cols,
              layout_rows: config.layout_rows,
              aURL: filteredAURL,
              aImages: config.aIMG.map((item, index) => {
                // Arrange all items from second to last in a sub-array
                const [first, ...rest] = item;
                return [first, rest, config.tileDelay[index]];
              }),
              aRSS: config.aRSS,
              settingsSource: "localStorage",
            };
            settings = configJSON;
            alert("\nSettings imported from config.js!\n\nRemember to Save Settings to Local Storage, Backup, or Export\n\nif you want to make changes permanent.");
            updateInputs();
            updateMenuTable();
            updateFeedTable();
            adjustDashboardItems();
          };
          reader.readAsText(file);
        };
        input.click();
      };

      document.getElementById("exportConfig").onclick = () => {
        const configJSContent = `// CUT START
var disableSetup = false; // Manually set to true to disable setup page menu option
var topBarCenterText = "${settings.topBarCenterText}";

// Grid layout desired
var layout_cols = ${settings.layout_cols};
var layout_rows = ${settings.layout_rows};

// Menu items
// Structure is as follows: HTML Color code, Option, target URL, scaling 1=Original Size, side (optional, nothing is Left, "R" is Right)
// The values are [color code, menu text, target link, scale factor, side],
// add new lines following the structure for extra menu options. The comma at the end is important!
var aURL = ${JSON.stringify(settings.aURL, null, 2)};

// Feed items
// Structure is as follows: target URL
// The values are [target link]
var aRSS = ${JSON.stringify(settings.aRSS, null, 2)};

// Dashboard Tiles items
// Tile Structure is Title, Source URL
// To display a website on the tiles use "iframe|" keyword before the tile URL
// [Title, Source URL],
// the comma at the end is important!
var aIMG = ${JSON.stringify(settings.aImages.map(item => [item[0], ...item[1].flat()]), null, 2)};

// Image rotation intervals in milliseconds per tile - If the line below is commented, tiles will be rotated every 5000 milliseconds (5s)
var tileDelay = ${JSON.stringify(settings.aImages.map(item => item[2]), null, 2)};

// CUT END`;

        const dataStr = "data:text/javascript;charset=utf-8," + encodeURIComponent(configJSContent);
        const downloadAnchorNode = document.createElement("a");
        downloadAnchorNode.setAttribute("href", dataStr);
        downloadAnchorNode.setAttribute("download", "config.js");
        document.body.appendChild(downloadAnchorNode);
        downloadAnchorNode.click();
        downloadAnchorNode.remove();
      };

      // Add New Menu Item
      document.getElementById("addMenuItem").onclick = () => {
        settings.aURL.push(["", "", "", "", ""]);
        updateMenuTable();
      };

      // Add New Feed Item
      document.getElementById("addFeedItem").onclick = () => {
        settings.aRSS.push([""]);
        updateFeedTable();
      };

      // Update Dashboard Items When Layout Changes
      document
        .getElementById("layout_cols")
        .addEventListener("change", () => {
          settings.layout_cols = parseInt(
            document.getElementById("layout_cols").value,
            10
          );
          adjustDashboardItems();
        });

      document
        .getElementById("layout_rows")
        .addEventListener("change", () => {
          settings.layout_rows = parseInt(
            document.getElementById("layout_rows").value,
            10
          );
          adjustDashboardItems();
        });

      function updateInputs() {
        if (settings.settingsSource) {
          document.querySelector(
            `input[name="settingsSource"][value="${settings.settingsSource}"]`
          ).checked = true;
        }
        document.getElementById("CenterText").value = settings.topBarCenterText;
        document.getElementById("layout_cols").value = settings.layout_cols;
        document.getElementById("layout_rows").value = settings.layout_rows;
      }

      // Function to update the variable and the display text
      function updateValue() {
        topBarCenterText = document.getElementById('CenterText').value;
        layout_cols = document.getElementById('layout_cols').value;
        layout_rows = document.getElementById('layout_rows').value;
        document.getElementById('topBarCenter').textContent = topBarCenterText;
        document.getElementById('layout_cols').textContent = layout_cols;
        document.getElementById('layout_rows').textContent = layout_rows;
      }
      // Add an onblur event listener to the input field
      document.getElementById('CenterText').onblur = updateValue;
      document.getElementById('layout_cols').onblur = updateValue;
      document.getElementById('layout_rows').onblur = updateValue;

    }); // End of DOMContentLoaded here
    // End Settings Section

    // db    db d888888b d888888b db      d888888b d888888b db    db      .d8888. d88888b  .o88b. d888888b d888888b  .d88b.  d8b   db
    // 88    88 `~~88~~'   `88'   88        `88'   `~~88~~' `8b  d8'      88'  YP 88'     d8P  Y8 `~~88~~'   `88'   .8P  Y8. 888o  88
    // 88    88    88       88    88         88       88     `8bd8'       `8bo.   88ooooo 8P         88       88    88    88 88V8o 88
    // 88    88    88       88    88         88       88       88           `Y8b. 88~~~~~ 8b         88       88    88    88 88 V8o88
    // 88b  d88    88      .88.   88booo.   .88.      88       88         db   8D 88.     Y8b  d8    88      .88.   `8b  d8' 88  V888
    // ~Y8888P'    YP    Y888888P Y88888P Y888888P    YP       YP         `8888Y' Y88888P  `Y88P'    YP    Y888888P  `Y88P'  VP   V8P

    function minimalConfiguration() {
      // Default settings
      window.disableSetup = false;
      window.curSettingsSrc = "None";
      window.topBarCenterText = "Use 'Setup' to configure your Ham Radio Dashboard";
      window.layout_cols = 0;
      window.layout_rows = 0;
      window.aURL = [];
      window.aIMG = [];
      window.aRSS = [];
      window.tileDelay = [];
      start();
    }

    // Helper to replace date placeholders
    function replaceDatePlaceholders(obj) {
      const now = new Date();
      const YYYYMMDD = now.toISOString().slice(0, 10).replace(/-/g, '');
      const DATE_ISO = now.toISOString().slice(0, 10);

      if (typeof obj === 'string') {
        return obj.replace(/{{YYYYMMDD}}/g, YYYYMMDD).replace(/{{DATE_ISO}}/g, DATE_ISO);
      } else if (Array.isArray(obj)) {
        return obj.map(replaceDatePlaceholders);
      } else if (typeof obj === 'object' && obj !== null) {
        Object.keys(obj).forEach(key => {
          obj[key] = replaceDatePlaceholders(obj[key]);
        });
        return obj;
      }
      return obj;
    }

    function processConfig(settings) {
      // Handle dynamic placeholders
      settings = replaceDatePlaceholders(settings);

      // Copy settings to window variables
      window.settingsSource = settings.settingsSource || "file";

      if (settings.disableSetup !== undefined) window.disableSetup = settings.disableSetup;
      if (settings.topBarCenterText) window.topBarCenterText = settings.topBarCenterText;
      if (settings.layout_cols) window.layout_cols = settings.layout_cols;
      if (settings.layout_rows) window.layout_rows = settings.layout_rows;
      if (settings.aURL) window.aURL = settings.aURL;
      if (settings.aRSS) window.aRSS = settings.aRSS;

      // Handle aIMG (supports both nested [Title, [Urls], Delay] and flat [Title, Url1, Url2...] formats)
      if (settings.aIMG) {
        window.aIMG = [];
        window.tileDelay = [];

        JSON.parse(JSON.stringify(settings.aIMG)).forEach((subArray) => {
          // subArray is [Title, [Urls], Delay]

          // Extract delay
          let delay = 30000;
          if (subArray.length >= 3) {
            delay = subArray[2];
          }
          window.tileDelay.push(delay);

          // Extract URLs and flatten
          // The main logic expects aIMG as [Title, Url1, Url2...]
          let flattened = [subArray[0]]; // Title
          if (Array.isArray(subArray[1])) {
            flattened.push(...subArray[1]);
          } else {
            flattened.push(subArray[1]);
          }
          window.aIMG.push(flattened);
        });
      } else if (settings.aImages) {
        // Fallback for old aImages (internal format)
        window.aIMG = [];
        window.tileDelay = [];

        JSON.parse(JSON.stringify(settings.aImages)).forEach((subArray) => {
          // subArray is [Title, [Urls], Delay]

          // Extract delay
          let delay = 30000;
          if (subArray.length >= 3) {
            delay = subArray[2];
          }
          window.tileDelay.push(delay);

          // Extract URLs and flatten
          let flattened = [subArray[0]]; // Title
          if (Array.isArray(subArray[1])) {
            flattened.push(...subArray[1]);
          } else {
            flattened.push(subArray[1]);
          }
          window.aIMG.push(flattened);
        });
      }

      start();
    }

    // ====================================================================
    // BREADCRUMB NAVIGATION SYSTEM
    // ====================================================================

    /**
     * Parse and validate the breadcrumb parameter from the current URL
     * @returns {Array<string>} Array of config filenames in the breadcrumb trail
     */
    function getCurrentBreadcrumb() {
      const urlParams = new URLSearchParams(window.location.search);
      const breadcrumbParam = urlParams.get('breadcrumb');

      if (!breadcrumbParam) return [];

      // Handle both encoded (%2B) and unencoded (+) separators
      // URLSearchParams automatically decodes %2B to +, so we can split on +
      const configs = breadcrumbParam.split('+').map(c => c.trim()).filter(c => c);

      // Validate config files (must end with .js or .json)
      const validated = configs.filter(config => {
        const valid = config.toLowerCase().endsWith('.js') || config.toLowerCase().endsWith('.json');
        if (!valid) {
          console.warn(`Breadcrumb: Skipping invalid config entry: ${config}`);
        }
        return valid;
      });

      return validated;
    }

    /**
     * Build a navigation URL with proper breadcrumb tracking
     * @param {string} targetConfig - The config file to navigate to
     * @returns {string} Constructed URL with breadcrumb parameter
     */
    function buildNavigationUrl(targetConfig) {
      const urlParams = new URLSearchParams(window.location.search);
      const currentConfig = urlParams.get('config') || 'config.js';

      // Get current breadcrumb trail
      let breadcrumb = getCurrentBreadcrumb();

      // Determine if current config is root
      const isCurrentRoot = (currentConfig === 'config.js' || currentConfig === 'config.json');
      const isTargetRoot = (targetConfig === 'config.js' || targetConfig === 'config.json');

      // If navigating to root, clear breadcrumb
      if (isTargetRoot) {
        return window.location.pathname + '?config=' + encodeURIComponent(targetConfig);
      }

      // Add current config to breadcrumb if not already root
      if (!isCurrentRoot) {
        // Prevent duplicate entries
        if (!breadcrumb.includes(currentConfig)) {
          breadcrumb.push(currentConfig);
        }
      } else {
        // If current is root, start fresh breadcrumb from root
        breadcrumb = [currentConfig];
      }

      // Limit breadcrumb depth to 10 items
      if (breadcrumb.length > 10) {
        breadcrumb = breadcrumb.slice(-10);
      }

      // Build URL with breadcrumb parameter
      const breadcrumbStr = breadcrumb.join('+');
      return window.location.pathname +
        '?breadcrumb=' + encodeURIComponent(breadcrumbStr) +
        '&config=' + encodeURIComponent(targetConfig);
    }

    /**
     * Build URL for navigating back to previous config in breadcrumb trail
     * @returns {string} URL for back navigation
     */
    function buildPreviousUrl() {
      const breadcrumb = getCurrentBreadcrumb();

      if (breadcrumb.length === 0) {
        // No breadcrumb, go to default root
        return window.location.pathname + '?config=config.js';
      }

      // Get the last config from breadcrumb (the one to navigate to)
      const previousConfig = breadcrumb[breadcrumb.length - 1];

      // Remove the last item to create truncated breadcrumb
      const truncatedBreadcrumb = breadcrumb.slice(0, -1);

      if (truncatedBreadcrumb.length === 0) {
        // Going back to root, no breadcrumb needed
        return window.location.pathname + '?config=' + encodeURIComponent(previousConfig);
      }

      // Build URL with truncated breadcrumb
      const breadcrumbStr = truncatedBreadcrumb.join('+');
      return window.location.pathname +
        '?breadcrumb=' + encodeURIComponent(breadcrumbStr) +
        '&config=' + encodeURIComponent(previousConfig);
    }

    // ====================================================================
    // END BREADCRUMB NAVIGATION SYSTEM
    // ====================================================================

    function ensureBackMenuItem(settings) {
      // Get current breadcrumb trail
      const breadcrumb = getCurrentBreadcrumb();

      // Only add PREVIOUS menu item if we have breadcrumb history
      if (breadcrumb.length === 0) {
        // No breadcrumb history, no PREVIOUS menu needed
        return;
      }

      // Initialize aURL if not exists
      if (!settings.aURL) {
        settings.aURL = [];
      }

      // Check if PREVIOUS menu item already exists
      const hasPrevious = settings.aURL.some(item => {
        if (!Array.isArray(item)) return false;
        const title = String(item[1] || '').toLowerCase();
        return title === 'previous' || title === 'prev';
      });

      if (hasPrevious) {
        // Already has PREVIOUS menu item, skip
        return;
      }

      // Get the previous config filename (last item in breadcrumb)
      const previousConfig = breadcrumb[breadcrumb.length - 1];

      // Add PREVIOUS menu item with color #212ff3
      // Store just the config filename, MenuOpt() will handle the navigation
      console.log(`Adding PREVIOUS menu item for breadcrumb navigation (back to: ${previousConfig})`);
      settings.aURL.unshift(["212ff3", "PREVIOUS", previousConfig, "1", "R"]);
    }

    function loadScriptConfig(url, fallback) {
      const script = document.createElement("script");
      script.src = url;
      script.onload = async () => {
        console.log(`${url} loaded successfully (script)`);

        // CHECK FOR NEW JSONP-STYLE CONFIG
        if (window.hamdashConfig) {
          console.log("Found window.hamdashConfig (JSONP)");
          window.curSettingsSrc = `${url} (Data Object)`;
          const settings = window.hamdashConfig;

          // Ensure navigation
          ensureBackMenuItem(settings);

          processConfig(settings);
          // Clear it so it doesn't pollute subsequent loads
          window.hamdashConfig = undefined;
          return;
        }

        // wait for config to finish any async work (Legacy JS logic support)
        if (window.configReady && typeof window.configReady.then === "function") {
          try { await window.configReady; } catch (e) { console.warn("configReady rejected:", e); }
        }

        // Legacy: config.js likely set window variables directly
        window.curSettingsSrc = `${url} (Legacy JS)`;

        // We still want to ensure back menu item even for legacy JS files if possible
        // But for legacy, we have to inspect the global window.aURL
        if (window.aURL) {
          // Re-wrap in a temporary object to use our helper
          const tempSettings = { aURL: window.aURL };
          ensureBackMenuItem(tempSettings);
          window.aURL = tempSettings.aURL;
        }

        start();
      };
      script.onerror = (error) => {
        console.error(`Failed to load ${url}:`, error);
        if (fallback) {
          console.log("Attempting fallback...");
          fallback();
        } else {
          minimalConfiguration();
        }
      };
      document.head.appendChild(script);
    }

    async function loadJsonConfig(url, fallback) {
      const isFileProtocol = window.location.protocol === "file:";
      if (isFileProtocol) {
        console.warn(`Loading JSON config ${url} via file:// protocol might fail due to CORS.`);
      }

      try {
        const response = await fetch(url + "?_=" + Date.now());
        if (!response.ok) {
          throw new Error(`Status ${response.status}`);
        }
        const settings = await response.json();
        console.log(`${url} loaded successfully`);
        window.curSettingsSrc = `${url} (JSON)`;

        ensureBackMenuItem(settings);
        processConfig(settings);
      } catch (e) {
        console.error(`Failed to load ${url}:`, e);
        if (fallback) {
          console.log("Attempting fallback from JSON load...");
          fallback(e);
        } else {
          minimalConfiguration();
        }
      }
    }

    async function loadConfig() {
      const urlParams = new URLSearchParams(window.location.search);
      let configParam = urlParams.get("config");

      // Smart config parameter cleaning: handle double-encoded URLs
      if (configParam) {
        // Check for double encoding (e.g., %253D instead of %3D)
        if (configParam.includes('%25')) {
          console.warn('Detected double-encoded URL, attempting to clean...');
          try {
            configParam = decodeURIComponent(configParam);
          } catch (e) {
            console.error('Failed to decode config parameter:', e);
          }
        }

        // Extract just the filename if it contains URL parameters or encoding issues
        // This handles cases like "config.js?breadcrumb=..." being passed as the config param
        if (configParam.includes('?')) {
          const parts = configParam.split('?');
          configParam = parts[0];
          console.warn(`Config parameter contained URL params, extracted: ${configParam}`);
        }
      }

      // Case 1: user specified a file
      if (configParam) {
        const isJson = configParam.toLowerCase().endsWith(".json");
        if (isJson) {
          loadJsonConfig(configParam, () => minimalConfiguration());
        } else {
          loadScriptConfig(configParam, () => minimalConfiguration());
        }
        return;
      }

      // Case 2: Default loading chain
      // Try config.js -> config.json -> Minimal
      console.log("No config specified, attempting default chain: config.js -> config.json");

      loadScriptConfig("config.js", () => {
        console.log("config.js failed, falling back to config.json");
        loadJsonConfig("config.json", () => {
          console.log("config.json failed, falling back to minimal");
          minimalConfiguration();
        });
      });
    }

    // Open OS file picker and reload page with the selected filename as ?config=<filename>
    function openConfigFileDialog() {
      let input = document.getElementById('configFileInput');
      if (!input) {
        input = document.createElement('input');
        input.type = 'file';
        input.accept = '.js,text/javascript,.json,application/json';
        input.id = 'configFileInput';
        input.style.display = 'none';
        input.addEventListener('change', (ev) => {
          const file = ev.target.files && ev.target.files[0];
          if (!file) return;
          const filename = file.name;
          // Use buildNavigationUrl to maintain breadcrumb chain
          const newUrl = buildNavigationUrl(filename);
          window.location.href = newUrl;
        });
        document.body.appendChild(input);
      }
      input.click();
    }

    async function main() {
      try {
        // Check if settings exist in localStorage
        const settings = localStorage.getItem('hamdash_config');
        if (settings) {
          // console.log('Settings found in localStorage:', settings);
          console.log('Settings found in localStorage');
          // Parse the settings JSON string
          const parsedSettings = JSON.parse(settings);
          window.settingsSource = parsedSettings.settingsSource;

          if (settingsSource === 'localStorage') {
            console.log('Loading settings from localStorage');
            window.curSettingsSrc = "Browser Local Storage";
            processConfig(parsedSettings);
          } else {
            console.log('Settings found in localStorage but loading from file');
            loadConfig();
          }
        } else {
          console.log('No settings found in localStorage');
          loadConfig();
        }
      } catch (error) {
        console.error('Failed to load configuration:', error);
        loadConfig();
      }
    }

    var help = "Double click on an image to expand to full screen.\n";
    help += "Double click again to close full screen view.\n";
    help += "Right click on an image to display the next one.\n";
    help += "Images rotates every 30 seconds automatically by default.\n";

    const currentVersion = "v2026.01.30";

    async function getLatestVersion() {
      try {
        const response = await fetch(
          "https://api.github.com/repos/VA3HDL/hamdashboard/releases/latest"
        );
        const data = await response.json();
        return data.tag_name;
      } catch (error) {
        console.error("Error fetching the latest version:", error);
        return currentVersion; // Fallback to the current version if there's an error
      }
    }

    function isNewVersionAvailable(currentVersion, latestVersion) {
      return currentVersion !== latestVersion;
    }

    bUpdate = false;
    async function checkForUpdates() {
      const latestVersion = await getLatestVersion();
      if (isNewVersionAvailable(currentVersion, latestVersion)) {
        bUpdate = true;
      }
    }

    const videoExtensions = [".mp4", ".webm", ".ogg", ".ogv"];

    function isVideo(src) {
      return videoExtensions.some((ext) => src.includes(ext));
    }

    function getVideoType(src) {
      if (src.includes(".mp4")) return "video/mp4";
      if (src.includes(".webm")) return "video/webm";
      if (src.includes(".ogg") || src.includes(".ogv")) return "video/ogg";
      return "";
    }

    function isFrame(src) {
      return src.includes("iframe|") || src.includes("iframedark|");
    }

    function isDarkFrame(src) {
      return src.includes("iframedark|");
    }

    function isDark(src) {
      return src.includes("dark|");
    }

    function isInvert(src) {
      return src.includes("invert|");
    }

    function oldformatArray(arr) {
      return arr.join("<br>");
    }

    function formatArray(arr) {
      return arr
        .map((innerArray) =>
          innerArray
            .map((item) => (typeof item === "string" ? `"${item}"` : item))
            .join(", ")
        )
        .join("<br>");
    }

    function setRot() {
      if (typeof tileDelay === "undefined") {
        // If no individual tile rotation is defined then default is 30s or 30000ms
        aInt[0] = setInterval(() => slide(), 30000);
      } else {
        tileDelay.forEach(function (tile, i) {
          if (tile > 0) {
            aInt[i] = setInterval(() => slide(i), tile);
          }
        });
      }
    }

    function rotStop() {
      if (typeof tileDelay === "undefined") {
        clearTimeout(aInt[0]);
      } else {
        tileDelay.forEach(function (tile, i) {
          clearTimeout(aInt[i]);
        });
      }
    }

    // This function shows the embedded websites
    function MenuOpt(num) {
      window.stop();
      rotStop();

      // If the menu title or URL is a config filename (e.g. "satellite.js" or "traffic.json"), reload with breadcrumb tracking
      const title = String(aURL[num][1] || "");
      const link = String(aURL[num][2] || "");
      const menuText = title;

      // Special handling for PREVIOUS button - use buildPreviousUrl() to truncate breadcrumb
      if (menuText.toUpperCase() === "PREVIOUS" || menuText.toUpperCase() === "PREV") {
        const previousUrl = buildPreviousUrl();
        window.location.href = previousUrl;
        return;
      }

      // Check if this is a config file navigation (.js or .json)
      const isConfigFile = title.toLowerCase().endsWith(".js") ||
        title.toLowerCase().endsWith(".json") ||
        link.toLowerCase().endsWith(".js") ||
        link.toLowerCase().endsWith(".json");

      if (isConfigFile) {
        // Prefer the explicit URL if it contains the filename, otherwise use the title
        const filename = (link.toLowerCase().endsWith(".js") || link.toLowerCase().endsWith(".json")) ? link : title;

        // Use buildNavigationUrl for breadcrumb-aware navigation
        const newUrl = buildNavigationUrl(filename);
        window.location.href = newUrl;
        return;
      }


      if (menuText.toLowerCase() == "refresh") {
        location.reload();
        setRot();
      } else if (menuText.toLowerCase() == "load cfg") {
        // open file picker and reload page with ?config=<filename>
        openConfigFileDialog();
        return;
      } else if (menuText.toLowerCase() == "help") {
        alert(help);
      } else if (menuText.toLowerCase() == "setup") {
        // Configure visualization
        document.getElementById("FullScreen").style.display = "none";
        document.getElementById("fixedSection").style.display = "block";
        document.getElementById("settingsPage").style.display = "block";
        document.getElementById("iFrameContainer").style.zIndex = 1;
        document.getElementById("iFrameContainer").style.backgroundColor = "black";
        if (curSettingsSrc === "local") {
          document.querySelector(`input[name="settingsSource"][value="localStorage"]`).checked = true;
        } else if (curSettingsSrc.includes("config.js")) {
          document.querySelector(`input[name="settingsSource"][value="file"]`).checked = true;
        }
        window.settings.topBarCenterText = topBarCenterText;
        window.settings.layout_cols = window.layout_cols;
        window.settings.layout_rows = window.layout_rows;
        document.getElementById("CenterText").value = window.settings.topBarCenterText;
        document.getElementById("layout_cols").value = window.settings.layout_cols;
        document.getElementById("layout_rows").value = window.settings.layout_rows;
        filteredAURL = aURL.filter(
          (item) =>
            !item.some(
              (subItem) =>
                typeof subItem === "string" &&
                (subItem.includes("BACK") ||
                  subItem.includes("Back") ||
                  subItem.includes("Refresh") ||
                  subItem.includes("Setup") ||
                  subItem.includes("Sources") ||
                  subItem.includes("Update") ||
                  subItem.includes("Help"))
            )
        );
        window.settings.aURL = filteredAURL;
        window.settings.aImages = aIMG.map((item, index) => {
          const [first, ...rest] = item;
          return [first, rest, tileDelay[index]];
        });
        window.settings.aRSS = aRSS;
        // Update the visualization on the Setup page
        updateMenuTable();
        updateFeedTable();
        adjustDashboardItems();
      } else if (menuText.toLowerCase() == "sources") {
        document.getElementById("array1").innerHTML =
          "<br>" + formatArray(aURL) + "<br><br>";
        document.getElementById("array2").innerHTML =
          "<br>" + formatArray(aIMG) + "<br><br>";
        document.getElementById("array3").innerHTML =
          "<br>" + formatArray(aRSS) + "<br><br>";
        document.getElementById("array4").innerHTML =
          `<br>Copyright (c) 2026 Pablo Sabbag, VA3HDL | Open Source License: MIT<br>
            <br>Dashboard codebase version: ${currentVersion}<br><br>`;
        document.getElementById("overlay").style.display = "block";
      } else if (menuText.toLowerCase() == "update") {
        window
          .open("https://github.com/VA3HDL/hamdashboard/releases/", "_blank")
          .focus();
      } else if (menuText.toLowerCase() == "back") {
        document.getElementById("FullScreen").src = "about:blank";
        document.getElementById("iFrameContainer").style.zIndex = -2;
        document.getElementById("iFrameContainer").style.backgroundColor = "black";
        document.getElementById("FullScreen").style.display = "none";
        document.getElementById("settingsPage").style.display = "none";
        setRot();
      } else {
        document.getElementById("iFrameContainer").style.zIndex = 1;
        document.getElementById("FullScreen").style.display = "block";
        var src = aURL[num][2];
        if (isDark(src)) {
          document.getElementById("FullScreen").style.filter = "invert(1) hue-rotate(180deg)";
          src = src.replace("dark|", "");
        } else {
          document.getElementById("FullScreen").style.filter = "none";
        }
        document.getElementById("FullScreen").src = src;
        document.getElementById("FullScreen").style.transform = "scale(" + aURL[num][3] + ")";
      }
    }

    function hideOverlay() {
      document.getElementById("overlay").style.display = "none";
    }

    // This function shows the larger images when double click to enlarge
    function larger(event) {
      var targetElement = event.target || event.srcElement;

      if (largeShow == 1) {
        // Start refreshes
        setRot();
        //
        largeShow = 0;
        document.getElementById("imgZoom").style.zIndex = -2;
      } else {
        // Stop refreshes
        window.stop();
        rotStop();
        //
        largeShow = 1;

        // Extract index more robustly (handles ClickOverlayN or ImageN)
        const idMatch = targetElement.id.match(/\d+/);
        if (!idMatch) {
          console.warn("Could not find index for zoom", targetElement.id);
          return;
        }
        largeIdx = +idMatch[0];

        const zoomContainer = document.getElementById("imgZoom");
        const largeImg = document.getElementById("ImageLarge");

        zoomContainer.style.zIndex = 3;

        // Find the source from the actual tile image
        const sourceImg = document.getElementById("Image" + largeIdx);
        if (sourceImg) {
          // WHEELZOOM COMPATIBILITY: 
          // If wheelzoom is active, sourceImg.src is a transparent placeholder.
          // The real image is in style.backgroundImage
          let realSrc = sourceImg.src;
          if (sourceImg.style.backgroundImage) {
            realSrc = sourceImg.style.backgroundImage.replace(/^url\(["']?/, "").replace(/["']?\)$/, "");
          }

          largeImg.src = realSrc;
        }
      }
    }

    // Image cache prevention
    // Check if the image URL already include parameters, then avoid the random timestamp
    function getImgURL(url) {
      return url.includes("?") ? url : url + "?_=" + Date.now();
    }

    // Manually rotate images
    function rotate(event) {
      event.preventDefault();
      var targetElement = event.target || event.srcElement;
      if (largeShow == 1) {
        i = largeIdx;
      } else {
        i = +targetElement.id.match(/\d+/)[0];
      }
      imgRot(i);
    }

    function imgRot(i) {
      if (aIMG[i].length > 2) {
        ++aIdx[i];
        if (aIdx[i] > aIMG[i].length - 1) {
          aIdx[i] = 1;
        }
      }

      // ROTATING TITLE LOGIC
      const titleDiv = document.getElementById("Title" + i);
      if (titleDiv && Array.isArray(aIMG[i][0])) {
        titleDiv.innerHTML = aIMG[i][0][aIdx[i] - 1] || "";
      }

      // Conditional overlay visibility (Lock/Unlock based on content type)
      const currentItem = aIMG[i][aIdx[i]];
      const overlay = document.getElementById('ClickOverlay' + i);
      if (overlay) {
        if (isVideo(currentItem) || isFrame(currentItem)) {
          overlay.style.display = 'block';
        } else {
          overlay.style.display = 'none';
        }
      }

      // console.log("aIdx", aIdx);
      // console.log("i", i, "aIdx[i]", aIdx[i], "aIMG[i][aIdx[i]]", aIMG[i][aIdx[i]]);
      vid = document.getElementById("Video" + i);
      img = document.getElementById("Image" + i);
      ifr = document.getElementById("iFrame" + i);

      const isImg = !isVideo(aIMG[i][aIdx[i]]) && !isFrame(aIMG[i][aIdx[i]]);
      const url = getImgURL(aIMG[i][aIdx[i]]);

      if (isVideo(aIMG[i][aIdx[i]])) {
        // Is video
        vid.src = url;
        vid.classList.remove("hidden");
        // Hide others
        img.classList.add("hidden");
        ifr.classList.add("hidden");
      } else if (isFrame(aIMG[i][aIdx[i]])) {
        // Is iFrame
        var src = aIMG[i][aIdx[i]];
        var newSrc = [];
        if (isDarkFrame(src)) {
          newSrc = src.split("iframedark|");
          ifr.style.filter = "invert(1) hue-rotate(180deg)";
        } else {
          newSrc = src.split("iframe|");
          ifr.style.filter = "none";
        }
        ifr.classList.remove("hidden");
        // Handle optional scale parameter: prefix|URL|SCALE
        var content = newSrc[1];
        var contentParts = content.split("|");
        ifr.src = contentParts[0];
        if (contentParts[1]) {
          ifr.style.transform = "scale(" + contentParts[1] + ")";
        }
        ifr.style.zIndex = 0;
        // Hide others
        vid.classList.add("hidden");
        img.classList.add("hidden");
      } else {
        // Is image
        var src = aIMG[i][aIdx[i]];
        if (isInvert(src)) {
          img.style.filter = "invert(1)";
          src = src.replace("invert|", "");
        } else {
          img.style.filter = "none";
        }
        img.src = getImgURL(src);
        img.classList.remove("hidden");
        // Hide others
        vid.classList.add("hidden");
        ifr.classList.add("hidden");
      }

      // FULL SCREEN ROTATION SUPPORT
      if (largeShow == 1 && i == largeIdx) {
        const largeImg = document.getElementById("ImageLarge");
        if (largeImg) {
          if (isImg) {
            largeImg.src = url;
          } else {
            // If we rotate into a non-image content, close the zoom view
            larger();
          }
        }
      }
    }

    // Automatically rotate images
    function slide(i) {
      // check all tiles or one tile
      if (typeof i === "undefined") {
        // get the locations with multiple images
        aIMG.forEach(function (innerArray, i) {
          imgRot(i);
        });
      } else {
        // Only one tile as per timeout call
        imgRot(i);
      }
    }

    function updateTickerSpeed() {
      const rssTickerContent = document.querySelector(".rss-ticker-content");
      if (rssTickerContent) {
        // Calculate the width of the content and the container
        const contentWidth = rssTickerContent.scrollWidth;
        const containerWidth = rssTickerContent.parentElement.offsetWidth;

        // Define a base speed (e.g., 180px per second)
        const baseSpeed = 180; // pixels per second

        // Calculate the duration based on the content width
        const duration = (contentWidth + containerWidth) / baseSpeed;

        // Update the CSS variable for the animation duration
        rssTickerContent.style.setProperty("--ticker-duration", `${duration}s`);
        // console.log(`Updated ticker speed: ${duration}s`);
      }
    }    
    
    // .......##.......##....########...######...######.
    // ......##.......##.....##.....##.##....##.##....##
    // .....##.......##......##.....##.##.......##......
    // ....##.......##.......########...######...######.
    // ...##.......##........##...##.........##.......##
    // ..##.......##.........##....##..##....##.##....##
    // .##.......##..........##.....##..######...######.
    
    // Store interval IDs to prevent duplicates
    let rssIntervals = [];
    let activeFetches = new Map(); // Track active fetch promises per feed URL
    // Track proxy success/failure rates per feed
    let proxyHealth = {};
      
    // Function to fetch and display the RSS feed
    function fetchAndDisplayRss() {
      // Clear any existing intervals first
      rssIntervals.forEach(intervalId => clearInterval(intervalId));
      rssIntervals = [];
      
      // List of CORS proxies to try (in order of preference)
      const corsProxies = [
        {
          name: 'allorigins',
          url: (feedUrl) => `https://api.allorigins.win/raw?url=${encodeURIComponent(feedUrl)}`
        },
        {
          name: 'corsproxy',
          url: (feedUrl) => `https://corsproxy.io/?url=${encodeURIComponent(feedUrl)}`
        },
        {
          name: 'codetabs',
          url: (feedUrl) => `https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(feedUrl)}`
        },
        {
          name: 'thingproxy',
          url: (feedUrl) => `https://thingproxy.freeboard.io/fetch/${feedUrl}`
        }
      ];
      
      const rssTickerContent = document.getElementById("rss-ticker-content");
      if (!rssTickerContent) {
        console.error("RSS ticker content element not found");
        return;
      }
      
      const feedContents = new Array(aRSS.length).fill("");
      let loadedFeeds = 0;
    
      console.log("Fetching RSS feeds...");
      
      aRSS.forEach(([rssUrl, interval], index) => {
        const fetchFeed = async (retryCount = 0, maxRetries = 1) => {
          // Prevent multiple simultaneous fetches of the same feed
          if (activeFetches.has(rssUrl)) {
            console.log(`⏸️ Fetch already in progress for ${rssUrl}, skipping...`);
            return;
          }
          
          console.log(`📡 Fetching feed: ${rssUrl}${retryCount > 0 ? ` (retry ${retryCount})` : ''}`);
          
          // Initialize proxy health tracking for this feed if needed
          if (!proxyHealth[rssUrl]) {
            proxyHealth[rssUrl] = {};
            corsProxies.forEach(proxy => {
              proxyHealth[rssUrl][proxy.name] = { successes: 0, failures: 0 };
            });
          }
          
          // Sort proxies by success rate for this specific feed
          const sortedProxies = [...corsProxies].sort((a, b) => {
            const healthA = proxyHealth[rssUrl][a.name];
            const healthB = proxyHealth[rssUrl][b.name];
            const rateA = healthA.successes / (healthA.successes + healthA.failures + 1);
            const rateB = healthB.successes / (healthB.successes + healthB.failures + 1);
            return rateB - rateA;
          });
          
          // Create the fetch promise and store it
          const fetchPromise = (async () => {
            try {
              // Try all proxies in parallel (race to success)
              const proxyPromises = sortedProxies.map(async (proxy) => {
                const proxyUrl = proxy.url(rssUrl);
                
                try {
                  const controller = new AbortController();
                  const timeoutId = setTimeout(() => controller.abort(), 8000); // 8 second timeout
                  
                  const response = await fetch(proxyUrl, { 
                    signal: controller.signal,
                    cache: 'no-cache',
                    headers: {
                      'Accept': 'application/rss+xml, application/xml, text/xml, application/atom+xml'
                    }
                  });
                  clearTimeout(timeoutId);
                  
                  if (!response.ok) {
                    throw new Error(`HTTP ${response.status}`);
                  }
                  
                  const data = await response.text();
                  
                  // Check if we actually got XML (not an HTML error page)
                  const trimmedData = data.trim();
                  if (!trimmedData.startsWith('<?xml') && 
                      !trimmedData.startsWith('<rss') && 
                      !trimmedData.startsWith('<feed') &&
                      !trimmedData.includes('<rss') &&
                      !trimmedData.includes('<feed')) {
                    throw new Error('Response is not XML');
                  }
                  
                  const parser = new DOMParser();
                  const xmlDoc = parser.parseFromString(data, "text/xml");
                  
                  // Check for XML parsing errors
                  const parserError = xmlDoc.querySelector('parsererror');
                  if (parserError) {
                    throw new Error('XML parsing error');
                  }
                
                  // Automatically detect whether the feed uses "item" or "entry" tags
                  let itmTag = "item"; // Default to RSS
                  if (xmlDoc.querySelector("entry")) {
                    itmTag = "entry"; // Switch to Atom if "entry" is found
                  }
                
                  const feedTitle = xmlDoc.querySelector("channel > title, feed > title")?.textContent || "Unknown Feed";
                  const lastUpdated = xmlDoc.querySelector("channel > lastBuildDate, feed > updated")?.textContent || "Unknown Time";
                
                  const items = xmlDoc.querySelectorAll(itmTag);
                
                  if (items.length === 0) {
                    throw new Error('No items found in feed');
                  }
                
                  // Success! Update proxy health
                  proxyHealth[rssUrl][proxy.name].successes++;
                  
                  console.log(`✅ Loaded ${items.length} items from ${rssUrl} (${proxy.name})`);
                
                  let feedText = `<span style="font-size: 0.9em; color: #aaa;"> ${feedTitle} - Last Updated: ${lastUpdated} </span> - `;
                
                  items.forEach((item) => {
                    const title = item.querySelector("title")?.textContent || "No title";
                  
                    // Handle both <link href="..."> and <link>...</link>
                    const linkElement = item.querySelector("link");
                    let link = "";
                    if (linkElement) {
                      if (linkElement.getAttribute("href")) {
                        link = linkElement.getAttribute("href");
                      } else {
                        link = linkElement.textContent.trim();
                      }
                    }
                  
                    feedText += `<a href="${link}" target="_blank" style="margin-right: 50px;">${title}</a>`;
                  });
                
                  // Return the successful result
                  return { index, feedText, proxy: proxy.name };
                  
                } catch (error) {
                  // Update proxy health on failure
                  proxyHealth[rssUrl][proxy.name].failures++;
                  // Only log significant errors
                  if (!error.message.includes('aborted') && !error.message.includes('Failed to fetch')) {
                    console.warn(`❌ ${proxy.name} failed for ${rssUrl}: ${error.message}`);
                  }
                  throw error; // Re-throw to be caught by Promise.any
                }
              });
              
              // Wait for the first successful proxy (race condition)
              const result = await Promise.any(proxyPromises);
              
              // Update the content for this feed (only once!)
              feedContents[index] = result.feedText;
              loadedFeeds++;
              
              // Combine all feeds and update the ticker content
              const displayContent = feedContents.filter(f => f).join("") || 
                `<span style="color: #aaa;">Loading feeds... (${loadedFeeds}/${aRSS.length})</span>`;
              rssTickerContent.innerHTML = displayContent;
              
              // Update the ticker speed
              updateTickerSpeed();
              
              return result;
              
            } catch (error) {
              // All proxies failed
              console.error(`🚫 All proxies failed for ${rssUrl}`);
              
              // Try retry if we haven't exceeded max retries
              if (retryCount < maxRetries) {
                const retryDelay = (retryCount + 1) * 3000; // 3s, 6s
                console.log(`⏳ Retrying ${rssUrl} in ${retryDelay/1000} seconds...`);
                
                // Remove from active fetches before retry
                activeFetches.delete(rssUrl);
                
                await new Promise(resolve => setTimeout(resolve, retryDelay));
                return fetchFeed(retryCount + 1, maxRetries);
              } else {
                // Final failure
                console.error(`💀 Giving up on ${rssUrl} after ${maxRetries + 1} attempts`);
                const domain = rssUrl.split('/')[2];
                feedContents[index] = `<span style="color: #f88; margin-right: 50px;">⚠️ ${domain} unavailable</span>`;
                rssTickerContent.innerHTML = feedContents.filter(f => f).join("") || 
                  '<span style="color: #f88;">Some feeds failed to load. Check console for details.</span>';
                throw error;
              }
            } finally {
              // Always remove from active fetches when done (success or failure)
              activeFetches.delete(rssUrl);
            }
          })();
          
          // Store the active fetch promise
          activeFetches.set(rssUrl, fetchPromise);
          
          // Wait for it to complete
          return fetchPromise;
        };
      
        // Fetch the feed immediately
        fetchFeed().catch(err => {
          console.error(`Failed to initialize feed ${rssUrl}:`, err);
        });
      
        // Set up periodic refresh based on the interval (in minutes)
        if (interval && interval > 0) {
          const intervalId = setInterval(() => {
            fetchFeed().catch(err => {
              console.error(`Failed to refresh feed ${rssUrl}:`, err);
            });
          }, interval * 60 * 1000);
          rssIntervals.push(intervalId);
        }
      });
    }

    // .d8888. d888888b  .d8b.  d8888b. d888888b
    // 88'  YP `~~88~~' d8' `8b 88  `8D `~~88~~'
    // `8bo.      88    88ooo88 88oobY'    88
    //   `Y8b.    88    88~~~88 88`8b      88
    // db   8D    88    88   88 88 `88.    88
    // `8888Y'    YP    YP   YP 88   YD    YP

    function start() {
      // Configurable grid layout logic. Defaults to standard 4 columns by 3 rows if values are missing in config.js file.
      var layout_cols = typeof window.layout_cols === "undefined" ? 4 : window.layout_cols;
      var layout_rows = typeof window.layout_rows === "undefined" ? 3 : window.layout_rows;
      var layout_grid = "auto ".repeat(layout_cols);
      var layout_width = 99.6 / layout_cols + "vw";
      var layout_height = 93 / layout_rows + "vh";
      var iTiles = layout_cols * layout_rows;
      document.documentElement.style.setProperty(
        "--main-layout",
        layout_grid
      );
      document.documentElement.style.setProperty(
        "--main-width",
        layout_width
      );
      document.documentElement.style.setProperty(
        "--main-height",
        layout_height
      );

      document.getElementById("currentSettingsSource").innerHTML = curSettingsSrc;

      // Default variables initialization
      window.largeShow = 0;
      window.aIdx = [];
      window.aInt = [];
      for (var i = 1; i <= iTiles; i++) {
        aIdx.push(1);
        aInt.push(null);
      }
      if (!(aIMG.length == tileDelay.length && aIMG.length == iTiles)) {
        var msg = "\nError detected on config.js file!\n\n";
        msg += "The number of tile sources (" + aIMG.length + " in aIMG) and\n";
        msg += "the tile delay (" + tileDelay.length + " in tileDelay) arrays should match\n";
        msg += "the number of items each one contains and\n";
        msg += "the number of tiles used on the layout specified (" + iTiles + ").";
        alert(msg);
      }

      // Get the parent div for Menu container
      var parentDivL = document.getElementById("myMenuL");
      var parentDivR = document.getElementById("myMenuR");

      // Preppend the Load Cfg option to the right side menu
      if (typeof disableLdCfg === "undefined" || !disableLdCfg) {
        aURL.unshift(
          ["FF0000", "Load Cfg", "", "1", "R"]
        )
      }

      // Preppend the default options to the menu
      aURL.unshift(
        ["add10d", "BACK", "", "1", "L"],
        ["0dd1a7", "Help", "", "1", "L"],
        ["add10d", "BACK", "", "1", "R"],
        ["ff9100", "Refresh", "?_=" + Date.now()],
      );

      // Append the Setup and Sources option to the right side menu
      if (typeof disableSetup === "undefined" || !disableSetup) {
        aURL.push(
          ["ff9100", "Setup", "", "1", "R"]
        )
      }

      aURL.push(
        ["0dd1a7", "Sources", "", "1", "R"]
      );

      // Append the Update option to the right side menu if needed
      if (bUpdate) {
        aURL.push(["FF0000", "Update", "", "1", "R"]);
      }

      // Append the new div to the parent div
      aURL.forEach(function (innerArray, index) {

        const title = String(innerArray[1] || '').trim();
        const link = String(innerArray[2] || '').trim();
        const titleLower = title.toLowerCase();
        const linkLower = link.toLowerCase();
        const coreNames = ['back', 'refresh', 'load cfg', 'help', 'setup', 'sources', 'update'];

        // Create a new div element
        var newDiv = document.createElement("div");
        var color = innerArray[0].replace("#", "");

        let type = 'user';
        if (coreNames.includes(titleLower))
          type = 'core';
        else if (titleLower.includes('.js') || linkLower.includes('.js'))
          type = 'config';

        newDiv.innerHTML = `<a href="#" class="menu-link menu-${type}" style="background-color:#${color};" onclick="MenuOpt(${index})">${innerArray[1]}</a>`;

        if (innerArray[4] == "R") {
          // Set some properties for the new div
          newDiv.id = "mySidenavR";
          newDiv.className = "sidenavR";
          parentDivR.appendChild(newDiv);
        } else {
          // Set some properties for the new div
          newDiv.id = "mySidenavL";
          newDiv.className = "sidenav";
          parentDivL.appendChild(newDiv);
        }
      });

      // Get the parent div for Dashboard container
      var parentDiv = document.getElementById("dash");

      // Append the new div to the parent div
      aIMG.forEach(function (innerArray, index) {
        // Create a new div element
        var newDiv = document.createElement("div");
        newDiv.className = "image-container";
        newDiv.id = `box${index}`;

        // Add video placeholder containers
        const video = document.createElement("video");
        video.id = `Video${index}`;
        video.classList.add("media", "hidden");
        video.controls = true;
        video.muted = true;
        video.autoplay = true;
        video.loop = true;
        const source = document.createElement("source");

        // Create a new img element
        var newImg = document.createElement("img");
        newImg.id = `Image${index}`;
        newImg.classList.add("hidden");
        newImg.oncontextmenu = rotate;
        newImg.ondblclick = larger;

        // append newIframes iFrameNN
        var newFrame = document.createElement("iframe");
        newFrame.className = "iframe-tile";
        newFrame.id = `iFrame${index}`;
        newFrame.classList.add("hidden");

        // CLICK OVERLAY (Fix for missing right-click on video/iframe)
        var clickOverlay = document.createElement("div");
        clickOverlay.className = "click-overlay";
        clickOverlay.id = `ClickOverlay${index}`;
        clickOverlay.oncontextmenu = rotate;

        // Initial visibility
        const initialItem = innerArray[1];
        if (isVideo(initialItem) || isFrame(initialItem)) {
          clickOverlay.style.display = 'block';
        } else {
          clickOverlay.style.display = 'none';
        }

        clickOverlay.ondblclick = function (event) {
          const currentItem = aIMG[index][aIdx[index]];
          if (isVideo(currentItem) || isFrame(currentItem)) {
            // If it's a video or iframe, UNLOCK it instead of zooming
            console.log(`Unlocking tile ${index} for interaction`);
            this.style.display = 'none';
          } else {
            // If it's an image, trigger the standard zoom
            larger(event);
          }
        };
        var newSrc = " ";

        if (isVideo(innerArray[1])) {
          // Is a video
          video.classList.remove("hidden");
          source.src = innerArray[1];
          source.type = getVideoType(innerArray[1]);
          video.appendChild(source);
        } else if (isFrame(innerArray[1])) {
          // Is iFrame
          newFrame.classList.remove("hidden");
          var src = innerArray[1];
          var newSrc = [];
          if (isDarkFrame(src)) {
            newSrc = src.split("iframedark|");
            newFrame.style.filter = "invert(1) hue-rotate(180deg)";
          } else {
            newSrc = src.split("iframe|");
            newFrame.style.filter = "none";
          }
          var content = newSrc[1];
          var contentParts = content.split("|");
          newFrame.src = contentParts[0];
          if (contentParts[1]) {
            newFrame.style.transform = "scale(" + contentParts[1] + ")";
          }
          newFrame.style.zIndex = 0;
        } else {
          // Is an image
          newImg.classList.remove("hidden");
          var src = innerArray[1];
          if (isInvert(src)) {
            newImg.style.filter = "invert(1)";
            src = src.replace("invert|", "");
          } else {
            newImg.style.filter = "none";
          }
          newImg.src = getImgURL(src);
          newImg.onerror = function () {
            text = "Failed to load image";
            console.log(text, this.src);
            if (this.src.includes("?")) {
              // Retry without passing variables first to see if fixes the error
              console.log("Trying without caching prevention");
              newImg.src = this.src.split("?")[0];
            } else {
              el = `<svg xmlns="http://www.w3.org/2000/svg" width="480" height="330">
                  <g>
                    <text style="font-size:34px; line-height:1.25; white-space:pre; fill:#ffaa00; fill-opacity:1; stroke:#ffaa00; stroke-opacity:1;">
                      <tspan x="100" y="150">${text}</tspan>
                      </text>
                      </g>
                      </svg>`;
              newImg.src = "data:image/svg+xml;base64," + window.btoa(el);
            }
          };
        }

        // append newDivs boxNN
        newDiv.appendChild(video);
        newDiv.appendChild(newImg);
        newDiv.appendChild(newFrame);
        newDiv.appendChild(clickOverlay);
        parentDiv.appendChild(newDiv);

        // Create a new div element for img title
        var newTtl = document.createElement("div");
        newTtl.className = "image-title";
        newTtl.id = `Title${index}`;

        let initialTitle = "";
        if (Array.isArray(innerArray[0])) {
          initialTitle = innerArray[0][0] || "";
        } else {
          initialTitle = innerArray[0];
        }

        if (initialTitle.length > 0 || Array.isArray(innerArray[0])) {
          newTtl.innerHTML = initialTitle;
          newDiv.appendChild(newTtl);
        }
      });

      // assign wheelzoom functionality to all 12 images
      wheelzoom(document.querySelectorAll("img"));

      window.addEventListener("resize", function () {
        "use strict";
        window.location.reload();
      });

      if (typeof aRSS !== "undefined" && aRSS.length > 0) {
        // Dynamically create the RSS ticker div
        const rssTicker = document.createElement("div");
        rssTicker.id = "rss-ticker";
        rssTicker.className = "rss-ticker";

        const rssTickerContent = document.createElement("div");
        rssTickerContent.id = "rss-ticker-content";
        rssTickerContent.className = "rss-ticker-content";

        rssTicker.appendChild(rssTickerContent);
        document.body.appendChild(rssTicker); // Add the ticker to the body

        // Call the function to fetch and display RSS feeds
        fetchAndDisplayRss();

        // Add event listeners for pause and resume functionality
        rssTickerContent.addEventListener("mouseenter", () => {
          rssTickerContent.style.animationPlayState = "paused";
        });

        rssTickerContent.addEventListener("mouseleave", () => {
          rssTickerContent.style.animationPlayState = "running";
        });
      }

      setRot();
    }

    // This function update the time on the top bar
    function updateTopBar() {
      const now = new Date();
      const localDate = now.toLocaleDateString("en-US", {
        weekday: "long",
        month: "long",
        day: "numeric",
      });
      const localTime = now.toLocaleTimeString("en-US", {
        hour12: false,
        hour: "2-digit",
        minute: "2-digit",
        second: "2-digit",
        timeZoneName: "short",
      });

      const utcDate = now.toISOString().slice(0, 10);
      const utcTime = now.toISOString().slice(11, 19) + " UTC";

      const topBarLeft = document.getElementById("topBarLeft");
      topBarLeft.textContent = `${localDate} - ${localTime}`;
      const topBarCenter = document.getElementById("topBarCenter");
      topBarCenter.textContent = topBarCenterText;
      const topBarRight = document.getElementById("topBarRight");
      topBarRight.textContent = `${utcDate} ${utcTime}`;
    }

    // Update every second
    setInterval(updateTopBar, 1000);

    // Run the check when the application starts
    checkForUpdates();
  </script>
</head>
<!--
d8888b.  .d88b.  d8888b. db    db
88  `8D .8P  Y8. 88  `8D `8b  d8'
88oooY' 88    88 88   88  `8bd8'
88~~~b. 88    88 88   88    88
88   8D `8b  d8' 88  .8D    88
Y8888P'  `Y88P'  Y8888D'    YP
-->

<body onload="main()">
  <div id="iFrameContainer" class="iframe-container">
    <iframe id="FullScreen" class="full-screen" src="" title="Zoom"></iframe>
    <!-- Settings Page Div -->
    <div id="settingsPage" class="settings-Page">
      <div id="fixedSection" class="fixed-section">
        <button id="saveConfig">Save Settings to Local Storage</button>
        <button id="resetConfig">Reset to Defaults</button>
        <button id="deleteConfig">Delete Settings from Local Storage</button>
        <button id="backupConfig">Backup Settings to JSON file</button>
        <button id="restoreConfig">Restore Settings from JSON file</button>
        <button id="importConfig">Import from config.js file</button>
        <button id="exportConfig">Export to config.js file</button>
      </div>

      <h1>Dashboard Setup</h1>
      <div id="configForm">
        <!-- Settings Source Selection -->
        <label>Current Settings Source:</label><span id="currentSettingsSource"></span>
        <div class="section">
          <div class="radio-group">
            <label>Select Settings Source:</label>
            <input type="radio" id="sourceLocalStorage" name="settingsSource" value="localStorage" />
            <label for="sourceLocalStorage">Browser Local Storage</label>
            <input type="radio" id="sourceFile" name="settingsSource" value="file" />
            <label for="sourceFile">config.js file</label>
            <font size=2em>(Please choose the source of the settings for the next time the dashboard is loaded.)</font>
          </div>
        </div>

        <!-- Top Bar Text -->
        <div class="section">
          <label for="CenterText">Top Bar Center Text:</label>
          <input type="text" id="CenterText" />
        </div>

        <!-- Grid Layout -->
        <div class="section">
          <label>Grid Layout:</label>
          <label for="layout_cols">Columns:</label>
          <input type="number" id="layout_cols" min="1" />
          <label for="layout_rows">Rows:</label>
          <input type="number" id="layout_rows" min="1" />
          <font size=2em>(The number of Dashboard Items table will adjust automatically for the grid layout selected
            here.)</font>
        </div>

        <!-- Menu Items -->
        <div class="section">
          <label>Menu Items:</label>
          <table id="menuTable">
            <thead>
              <tr>
                <th>Color</th>
                <th>Text</th>
                <th>URL</th>
                <th>Scale</th>
                <th>Side</th>
                <th>Actions</th>
              </tr>
            </thead>
            <tbody></tbody>
          </table>
          <button id="addMenuItem">Add Menu Item</button>
        </div>

        <!-- Dashboard Items -->
        <div class="section">
          <label>Dashboard Items:</label>
          <table id="dashboardTable">
            <thead>
              <tr>
                <th>Tile Title</th>
                <th>Tile URLs</th>
                <th>URL Rotation Interval (ms)</th>
                <th>Actions</th>
              </tr>
            </thead>
            <tbody></tbody>
          </table>
        </div>

        <!-- Feed Items -->
        <div class="section">
          <label>Feed Items:</label>
          <table id="feedTable">
            <thead>
              <tr>
                <th>Feed URL</th>
                <th>Refresh Interval (minutes)</th>
                <th>Actions</th>
              </tr>
            </thead>
            <tbody></tbody>
          </table>
          <button id="addFeedItem">Add Feed Item</button>
        </div>

      </div>
    </div> <!-- End of Div Settings page -->
  </div>

  <div id="imgZoom" class="img-zoom">
    <img class="image-large" id="ImageLarge" alt="pic" ondblclick="larger(event);" oncontextmenu="rotate(event);" />
  </div>

  <div id="myMenuL" class="menuL">
    <!-- Left Menu container -->
  </div>

  <div id="myMenuR" class="menuR">
    <!-- Right Menu container -->
  </div>

  <div id="defaultFrame" class="default-frame">
    <div class="top-bar">
      <div id="topBarLeft" class="child" style="text-align: left; padding-left: 7px; color: blanchedalmond">
        &nbsp;
      </div>
      <div id="topBarCenter" class="child" style="text-align: center; color: rgb(0, 119, 255)">
        &nbsp;
      </div>
      <div id="topBarRight" class="child" style="text-align: right; padding-right: 5px; color: aquamarine">
        &nbsp;
      </div>
    </div>
    <div id="dash" class="dashboard">
      <!-- Images container -->
    </div>
  </div>

  <div id="overlay" class="overlay">
    <div class="close-btn" onclick="hideOverlay()">&#10006;</div>
    <div class="overlay-content">
      <div class="array-container">
        <div class="array-title">
          <b>Full config.js file: </b>
          <a href="config.js" target="_blank" style="color: white">(Open in new tab)</a>
          <p></p>
        </div>
      </div>
      <div class="array-container">
        <div class="array-title"><b>Menu Options:</b></div>
        <div id="array1" class="array-content"></div>
      </div>
      <div class="array-container">
        <div class="array-title"><b>Image Sources:</b></div>
        <div id="array2" class="array-content"></div>
      </div>
      <div class="array-container">
        <div class="array-title"><b>Feed Sources:</b></div>
        <div id="array3" class="array-content"></div>
      </div>
      <div class="array-container">
        <div class="array-title"><b>Development by:</b></div>
        <div id="array4" class="array-content"></div>
      </div>
    </div>
  </div>

</body>

</html>

================================================
FILE: satellite.js
================================================
// CUT START
// const disableSetup = true;
var topBarCenterText = "Satellite Dashboard";

// Grid layout desired
var layout_cols = 2;
var layout_rows = 2;

// Menu items
// Structure is as follows HTML Color code, Option, target URL, scaling 1=Original Size, side (optional, nothing is Left, "R" is Right)
// The values are [color code, menu text, target link, scale factor, side],
// add new lines following the structure for extra menu options. The comma at the end is important!
var aURL = [  
  [
    "2196F3",
    "LIGHTNING",
    "https://map.blitzortung.org/#3.87/36.5/-89.41",
    "1",
    "R",
  ],
  [
    "2196F3",
    "RADAR",
    "https://weather.gc.ca/?layers=alert,radar&center=43.39961001,-78.53212031&zoom=6&alertTableFilterProv=ON",
    "1",
    "R",
  ],
  [
    "2196F3",
    "WEATHER",
    "https://openweathermap.org/weathermap?basemap=map&cities=true&layer=temperature&lat=44.0157&lon=-79.4591&zoom=5",
    "1",
    "R",
  ],
  [
    "2196F3",
    "WINDS",
    "https://earth.nullschool.net/#current/wind/surface/level/orthographic=-78.79,44.09,3000",
    "1",
    "R",
  ],
  [
    "2196F3",
    "WINDY",
    "https://embed.windy.com/embed2.html?lat=44.01&lon=-79.45&width=900&detailLat=44.01&detailLon=-79.45&height=600&zoom=8&level=surface&overlay=clouds&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=true&metricWind=km%2Fh&metricTemp=%C2%B0C&radarRange=-1",
    "1",
    "R"
  ]
];

// Dashboard items
// Structure is Title, Image Source URL
// [Title, Image Source URL],
// the comma at the end is important!
// You can't add more items because there are only 12 placeholders on the dashboard
// but you can replace the titles and the images with anything you want.
var currentDate = new Date();
var aIMG = [
  // 1
  ["Radar NA", "https://radar.weather.gov/ridge/standard/CONUS-LARGE_loop.gif", "https://radar.weather.gov/ridge/standard/CONUS_loop.gif"],
  // 2
  [
    "Radar Local",
    "https://s.w-x.co/staticmaps/wu/wxtype/county_loc/bgm/animate.png",
  ],
  // 3
  [
    "Satellite NA (inverted)",
    "invert|https://cdn.star.nesdis.noaa.gov/GOES16/ABI/SECTOR/can/GEOCOLOR/GOES16-CAN-GEOCOLOR-1125x560.gif",
  ],
  // 4
  [
    "Satellite Local (inverted)",
    "invert|https://cdn.star.nesdis.noaa.gov/GOES16/GLM/SECTOR/cgl/EXTENT3/GOES16-CGL-EXTENT3-600x600.gif",
  ],
];

// Image rotation intervals in milliseconds per tile - If the line below is commented, tiles will be rotated every 5000 milliseconds (5s)
var tileDelay = [
  60100, 60200, 300300, 60400,
];

var aRSS = [
  ["https://weather.gc.ca/rss/battleboard/onrm28_e.xml", 60],
];

// CUT END


================================================
FILE: wheelzoom.js
================================================
/*!
	Wheelzoom 4.0.1
	license: MIT
	http://www.jacklmoore.com/wheelzoom
*/
window.wheelzoom = (function(){
	var defaults = {
		zoom: 0.10,
		maxZoom: false,
		initialZoom: 1,
		initialX: 0.5,
		initialY: 0.5,
	};

	var main = function(img, options){
		if (!img || !img.nodeName || img.nodeName !== 'IMG') { return; }

		var settings = {};
		var width;
		var height;
		var bgWidth;
		var bgHeight;
		var bgPosX;
		var bgPosY;
		var previousEvent;
		var transparentSpaceFiller;

		function setSrcToBackground(img) {
			img.style.backgroundRepeat = 'no-repeat';
			img.style.backgroundImage = 'url("'+img.src+'")';
			transparentSpaceFiller = 'data:image/svg+xml;base64,'+window.btoa('<svg xmlns="http://www.w3.org/2000/svg" width="'+img.naturalWidth+'" height="'+img.naturalHeight+'"></svg>');
			img.src = transparentSpaceFiller;
		}

		function updateBgStyle() {
			if (bgPosX > 0) {
				bgPosX = 0;
			} else if (bgPosX < width - bgWidth) {
				bgPosX = width - bgWidth;
			}

			if (bgPosY > 0) {
				bgPosY = 0;
			} else if (bgPosY < height - bgHeight) {
				bgPosY = height - bgHeight;
			}

			img.style.backgroundSize = bgWidth+'px '+bgHeight+'px';
			img.style.backgroundPosition = bgPosX+'px '+bgPosY+'px';
		}

		function reset() {
			bgWidth = width;
			bgHeight = height;
			bgPosX = bgPosY = 0;
			updateBgStyle();
		}

		function onwheel(e) {
			var deltaY = 0;

			e.preventDefault();

			if (e.deltaY) { // FireFox 17+ (IE9+, Chrome 31+?)
				deltaY = e.deltaY;
			} else if (e.wheelDelta) {
				deltaY = -e.wheelDelta;
			}

			// As far as I know, there is no good cross-browser way to get the cursor position relative to the event target.
			// We have to calculate the target element's position relative to the document, and subtrack that from the
			// cursor's position relative to the document.
			var rect = img.getBoundingClientRect();
			var offsetX = e.pageX - rect.left - window.pageXOffset;
			var offsetY = e.pageY - rect.top - window.pageYOffset;

			// Record the offset between the bg edge and cursor:
			var bgCursorX = offsetX - bgPosX;
			var bgCursorY = offsetY - bgPosY;
			
			// Use the previous offset to get the percent offset between the bg edge and cursor:
			var bgRatioX = bgCursorX/bgWidth;
			var bgRatioY = bgCursorY/bgHeight;

			// Update the bg size:
			if (deltaY < 0) {
				bgWidth += bgWidth*settings.zoom;
				bgHeight += bgHeight*settings.zoom;
			} else {
				bgWidth -= bgWidth*settings.zoom;
				bgHeight -= bgHeight*settings.zoom;
			}

			if (settings.maxZoom) {
				bgWidth = Math.min(width*settings.maxZoom, bgWidth);
				bgHeight = Math.min(height*settings.maxZoom, bgHeight);
			}

			// Take the percent offset and apply it to the new size:
			bgPosX = offsetX - (bgWidth * bgRatioX);
			bgPosY = offsetY - (bgHeight * bgRatioY);

			// Prevent zooming out beyond the starting size
			if (bgWidth <= width || bgHeight <= height) {
				reset();
			} else {
				updateBgStyle();
			}
		}

		function drag(e) {
			e.preventDefault();
			bgPosX += (e.pageX - previousEvent.pageX);
			bgPosY += (e.pageY - previousEvent.pageY);
			previousEvent = e;
			updateBgStyle();
		}

		function removeDrag() {
			document.removeEventListener('mouseup', removeDrag);
			document.removeEventListener('mousemove', drag);
		}

		// Make the background draggable
		function draggable(e) {
			e.preventDefault();
			previousEvent = e;
			document.addEventListener('mousemove', drag);
			document.addEventListener('mouseup', removeDrag);
		}

		function load() {
			var initial = Math.max(settings.initialZoom, 1);

			if (img.src === transparentSpaceFiller) return;

			var computedStyle = window.getComputedStyle(img, null);

			width = parseInt(computedStyle.width, 10);
			height = parseInt(computedStyle.height, 10);
			bgWidth = width * initial;
			bgHeight = height * initial;
			bgPosX = -(bgWidth - width) * settings.initialX;
			bgPosY = -(bgHeight - height) * settings.initialY;

			setSrcToBackground(img);

			img.style.backgroundSize = bgWidth+'px '+bgHeight+'px';
			img.style.backgroundPosition = bgPosX+'px '+bgPosY+'px';
			img.addEventListener('wheelzoom.reset', reset);

			img.addEventListener('wheel', onwheel);
			img.addEventListener('mousedown', draggable);
		}

		var destroy = function (originalProperties) {
			img.removeEventListener('wheelzoom.destroy', destroy);
			img.removeEventListener('wheelzoom.reset', reset);
			img.removeEventListener('load', load);
			img.removeEventListener('mouseup', removeDrag);
			img.removeEventListener('mousemove', drag);
			img.removeEventListener('mousedown', draggable);
			img.removeEventListener('wheel', onwheel);

			img.style.backgroundImage = originalProperties.backgroundImage;
			img.style.backgroundRepeat = originalProperties.backgroundRepeat;
			img.src = originalProperties.src;
		}.bind(null, {
			backgroundImage: img.style.backgroundImage,
			backgroundRepeat: img.style.backgroundRepeat,
			src: img.src
		});

		img.addEventListener('wheelzoom.destroy', destroy);

		options = options || {};

		Object.keys(defaults).forEach(function(key){
			settings[key] = options[key] !== undefined ? options[key] : defaults[key];
		});

		if (img.complete) {
			load();
		}

		img.addEventListener('load', load);
	};

	// Do nothing in IE9 or below
	if (typeof window.btoa !== 'function') {
		return function(elements) {
			return elements;
		};
	} else {
		return function(elements, options) {
			if (elements && elements.length) {
				Array.prototype.forEach.call(elements, function (node) {
					main(node, options);
				});
			} else if (elements && elements.nodeName) {
				main(elements, options);
			}
			return elements;
		};
	}
}());
Download .txt
gitextract_72fn_dc0/

├── .gitignore
├── LICENSE
├── README.md
├── config.js
├── config.json
├── config_jsonp.js
├── hamdash.html
├── satellite.js
└── wheelzoom.js
Download .txt
SYMBOL INDEX (8 symbols across 1 files)

FILE: wheelzoom.js
  function setSrcToBackground (line 28) | function setSrcToBackground(img) {
  function updateBgStyle (line 35) | function updateBgStyle() {
  function reset (line 52) | function reset() {
  function onwheel (line 59) | function onwheel(e) {
  function drag (line 111) | function drag(e) {
  function removeDrag (line 119) | function removeDrag() {
  function draggable (line 125) | function draggable(e) {
  function load (line 132) | function load() {
Condensed preview — 9 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (129K chars).
[
  {
    "path": ".gitignore",
    "chars": 15,
    "preview": ".vscode\n*sync*\n"
  },
  {
    "path": "LICENSE",
    "chars": 1077,
    "preview": "MIT License\n\nCopyright (c) 2024 Pablo Sabbag, VA3HDL\n\nPermission is hereby granted, free of charge, to any person obtain"
  },
  {
    "path": "README.md",
    "chars": 11997,
    "preview": "# Ham Dashboard (hamdashboard)\n\nLive demo: [Hamdash Demo](https://va3hdl.github.io/hamdash/)\n\nThis repository provides a"
  },
  {
    "path": "config.js",
    "chars": 3652,
    "preview": "const disableSetup = false;\nconst disableLdCfg = false;\nvar topBarCenterText = `VA3HDL - FN04ga - .js`;\n\n// Grid layout\n"
  },
  {
    "path": "config.json",
    "chars": 2497,
    "preview": "{\n  \"disableSetup\": false,\n  \"disableLdCfg\": false,\n  \"topBarCenterText\": \"VA3HDL - FN04ga - JSON\",\n  \"layout_cols\": 4,\n"
  },
  {
    "path": "config_jsonp.js",
    "chars": 2518,
    "preview": "window.hamdashConfig = {\n  \"disableSetup\": false,\n  \"disableLdCfg\": false,\n  \"topBarCenterText\": \"VA3HDL-FN04ga-JsonP\",\n"
  },
  {
    "path": "hamdash.html",
    "chars": 92803,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n  <meta charset=\"UTF-8\" />\n  <meta http-equiv=\"Cache-Control\" content=\"no-cache"
  },
  {
    "path": "satellite.js",
    "chars": 2643,
    "preview": "// CUT START\n// const disableSetup = true;\nvar topBarCenterText = \"Satellite Dashboard\";\n\n// Grid layout desired\nvar lay"
  },
  {
    "path": "wheelzoom.js",
    "chars": 5662,
    "preview": "/*!\n\tWheelzoom 4.0.1\n\tlicense: MIT\n\thttp://www.jacklmoore.com/wheelzoom\n*/\nwindow.wheelzoom = (function(){\n\tvar defaults"
  }
]

About this extraction

This page contains the full source code of the VA3HDL/hamdashboard GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 9 files (120.0 KB), approximately 31.4k tokens, and a symbol index with 8 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!