Initial commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# === The Dark Elite Hub - environment config ===
|
||||
# Copy this file to .env and fill in real values before running deploy.sh
|
||||
|
||||
# Password for accessing the hub from outside the LAN
|
||||
HUB_PASSWORD=change-me-to-a-real-password
|
||||
|
||||
# Cryptographic secret for signing session cookies.
|
||||
# Generate with: openssl rand -hex 32
|
||||
HUB_SECRET=replace-with-output-of-openssl-rand-hex-32
|
||||
|
||||
# Optional: change ports if 80/443 are taken on the host
|
||||
CADDY_HTTP_PORT=80
|
||||
CADDY_HTTPS_PORT=443
|
||||
@@ -0,0 +1,51 @@
|
||||
# The Dark Elite Hub - Portable Deployment
|
||||
|
||||
Self-hosted SHTF knowledge hub with offline Wikipedia, maps, weather radar,
|
||||
PDF library, ham radio frequencies, supplies inventory, overlay route plotting,
|
||||
garden calendar, unified search, and password-gated remote access.
|
||||
|
||||
## Quick start
|
||||
|
||||
sudo ./deploy.sh
|
||||
|
||||
## Configuration
|
||||
|
||||
Before deploying, copy .env.example to .env and edit:
|
||||
|
||||
- HUB_PASSWORD - password for external login
|
||||
- HUB_SECRET - random key for cookies (run: openssl rand -hex 32)
|
||||
- CADDY_HTTP_PORT / CADDY_HTTPS_PORT - change if 80/443 are taken
|
||||
|
||||
deploy.sh auto-generates HUB_SECRET if missing.
|
||||
|
||||
## Module URLs (after deploy)
|
||||
|
||||
- Dashboard: /
|
||||
- Library: /library/
|
||||
- Maps: /maps/
|
||||
- Weather: /weather/
|
||||
- PDFs: /pdfs/
|
||||
- Frequencies: /freqs/
|
||||
- Inventory: /inventory/
|
||||
- Search: /search/
|
||||
- Overlay: /overlay/
|
||||
- Garden: /garden/
|
||||
- Login: /login/
|
||||
- Logout: /logout
|
||||
|
||||
## Not included
|
||||
|
||||
- ZIM files (upload at /library/ after deploy)
|
||||
- PDF files (upload at /pdfs/ after deploy)
|
||||
- Map tiles (run fetch-maps.sh)
|
||||
|
||||
## LAN bypass
|
||||
|
||||
Requests from 10.0.0.0/24, 192.168.0.0/16, and 127.0.0.1 skip login.
|
||||
Edit skel/auth/data/app.py before tarball-ing if your LAN uses a different subnet.
|
||||
|
||||
## Uninstall
|
||||
|
||||
cd /opt/hub-deploy
|
||||
docker compose down -v
|
||||
sudo rm -rf /opt/hub
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# The Dark Elite Hub - Portable Deployment
|
||||
|
||||
Self-hosted SHTF knowledge hub with offline Wikipedia, maps, weather radar,
|
||||
PDF library, ham radio frequencies, supplies inventory, overlay route plotting,
|
||||
garden calendar, unified search, and password-gated remote access.
|
||||
|
||||
## Quick start
|
||||
|
||||
sudo ./deploy.sh
|
||||
|
||||
## Configuration
|
||||
|
||||
Before deploying, copy .env.example to .env and edit:
|
||||
|
||||
- HUB_PASSWORD - password for external login
|
||||
- HUB_SECRET - random key for cookies (run: openssl rand -hex 32)
|
||||
- CADDY_HTTP_PORT / CADDY_HTTPS_PORT - change if 80/443 are taken
|
||||
|
||||
deploy.sh auto-generates HUB_SECRET if missing.
|
||||
|
||||
## Module URLs (after deploy)
|
||||
|
||||
- Dashboard: /
|
||||
- Library: /library/
|
||||
- Maps: /maps/
|
||||
- Weather: /weather/
|
||||
- PDFs: /pdfs/
|
||||
- Frequencies: /freqs/
|
||||
- Inventory: /inventory/
|
||||
- Search: /search/
|
||||
- Overlay: /overlay/
|
||||
- Garden: /garden/
|
||||
- Login: /login/
|
||||
- Logout: /logout
|
||||
|
||||
## Not included
|
||||
|
||||
- ZIM files (upload at /library/ after deploy)
|
||||
- PDF files (upload at /pdfs/ after deploy)
|
||||
- Map tiles (run fetch-maps.sh)
|
||||
|
||||
## LAN bypass
|
||||
|
||||
Requests from 10.0.0.0/24, 192.168.0.0/16, and 127.0.0.1 skip login.
|
||||
Edit skel/auth/data/app.py before tarball-ing if your LAN uses a different subnet.
|
||||
|
||||
## Uninstall
|
||||
|
||||
cd /opt/hub-deploy
|
||||
docker compose down -v
|
||||
sudo rm -rf /opt/hub
|
||||
Binary file not shown.
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
CYAN='\033[0;36m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${CYAN}========================================${NC}"
|
||||
echo -e "${CYAN} THE DARK ELITE HUB - DEPLOYMENT${NC}"
|
||||
echo -e "${CYAN}========================================${NC}"
|
||||
echo
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Run with sudo.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo -e "${YELLOW}No .env file found. Creating from .env.example...${NC}"
|
||||
cp .env.example .env
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
SECRET=$(openssl rand -hex 32)
|
||||
sed -i "s|HUB_SECRET=.*|HUB_SECRET=$SECRET|" .env
|
||||
echo -e "${GREEN}Auto-generated HUB_SECRET in .env${NC}"
|
||||
fi
|
||||
echo
|
||||
echo -e "${YELLOW}You must edit .env now and set HUB_PASSWORD.${NC}"
|
||||
echo -e "Press ENTER after you have edited .env, or Ctrl+C to abort."
|
||||
read
|
||||
fi
|
||||
|
||||
if grep -q "change-me-to-a-real-password\|replace-with" .env; then
|
||||
echo -e "${RED}.env still has placeholder values. Edit it before deploying.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo -e "${CYAN}==> Installing Docker...${NC}"
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable --now docker
|
||||
fi
|
||||
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
echo -e "${RED}Docker Compose plugin missing. Install docker-compose-plugin and re-run.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}==> Setting up /opt/hub from skeleton...${NC}"
|
||||
mkdir -p /opt/hub
|
||||
rsync -a --ignore-existing skel/ /opt/hub/
|
||||
mkdir -p /opt/hub/caddy/data /opt/hub/caddy/config
|
||||
chmod -R u+rw /opt/hub
|
||||
|
||||
if ! docker network inspect hub >/dev/null 2>&1; then
|
||||
echo -e "${CYAN}==> Creating hub network...${NC}"
|
||||
docker network create hub
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}==> Pulling images and starting stacks...${NC}"
|
||||
docker compose --env-file .env up -d
|
||||
|
||||
echo
|
||||
echo -e "${CYAN}==> Waiting for services...${NC}"
|
||||
sleep 15
|
||||
|
||||
IP=$(hostname -I | awk '{print $1}')
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} DEPLOY COMPLETE${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo
|
||||
echo -e "Hub URL: ${CYAN}http://$IP/${NC}"
|
||||
echo -e "Login: ${CYAN}http://$IP/login/${NC}"
|
||||
echo
|
||||
echo -e "${YELLOW}Note: ZIMs and PDFs are EMPTY. Upload via:${NC}"
|
||||
echo -e " Library admin: http://$IP/library/ (top of page)"
|
||||
echo -e " PDF library: http://$IP/pdfs/"
|
||||
echo
|
||||
echo -e "${CYAN}========================================${NC}"
|
||||
echo -e "${CYAN} MAP TILES${NC}"
|
||||
echo -e "${CYAN}========================================${NC}"
|
||||
echo
|
||||
echo "Maps page and Overlay page require base.pmtiles."
|
||||
echo "Download size: ~4-25 GB depending on detail level."
|
||||
echo "Download time: 10 min - 1 hour."
|
||||
echo
|
||||
read -p "Download North America map tiles now? [Y/n]: " yn
|
||||
yn=${yn:-Y}
|
||||
if [[ "$yn" =~ ^[Yy]$ ]]; then
|
||||
bash "$SCRIPT_DIR/fetch-maps.sh"
|
||||
else
|
||||
echo
|
||||
echo -e "${YELLOW}Skipped.${NC}"
|
||||
echo -e "Run ${CYAN}$SCRIPT_DIR/fetch-maps.sh${NC} anytime to download maps later."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}Done.${NC}"
|
||||
@@ -0,0 +1,158 @@
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${CADDY_HTTP_PORT:-80}:80"
|
||||
- "${CADDY_HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- /opt/hub/caddy/Caddyfile:/etc/caddy/Caddyfile
|
||||
- /opt/hub/caddy/data:/data
|
||||
- /opt/hub/caddy/config:/config
|
||||
- /opt/hub/maps/www:/srv/maps:ro
|
||||
- /opt/hub/weather/www:/srv/weather:ro
|
||||
- /opt/hub/garden/www:/srv/garden:ro
|
||||
- /opt/hub/library/www:/srv/library:ro
|
||||
networks:
|
||||
- hub
|
||||
|
||||
homepage:
|
||||
image: ghcr.io/gethomepage/homepage:latest
|
||||
container_name: homepage
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- HOMEPAGE_ALLOWED_HOSTS=*
|
||||
volumes:
|
||||
- /opt/hub/homepage/config:/app/config
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- hub
|
||||
|
||||
auth:
|
||||
image: python:3.12-slim
|
||||
container_name: auth
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
environment:
|
||||
- HUB_PASSWORD=${HUB_PASSWORD:?HUB_PASSWORD not set in .env}
|
||||
- HUB_SECRET=${HUB_SECRET:?HUB_SECRET not set in .env}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "pip install --quiet fastapi 'uvicorn[standard]' python-multipart && uvicorn app:app --host 0.0.0.0 --port 8000"
|
||||
volumes:
|
||||
- /opt/hub/auth/data:/app
|
||||
- /opt/hub/auth/data/static:/static
|
||||
networks:
|
||||
- hub
|
||||
|
||||
kiwix:
|
||||
image: ghcr.io/kiwix/kiwix-serve:latest
|
||||
container_name: kiwix
|
||||
restart: unless-stopped
|
||||
entrypoint: ["sh", "-c"]
|
||||
command: ["kiwix-serve --port=8080 --urlRootLocation=/library /data/*.zim 2>/dev/null || (echo 'No ZIMs yet - upload via /library/' && tail -f /dev/null)"]
|
||||
volumes:
|
||||
- /opt/hub/kiwix/zims:/data
|
||||
networks:
|
||||
- hub
|
||||
|
||||
pdfs:
|
||||
image: python:3.12-slim
|
||||
container_name: pdfs
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "pip install --quiet fastapi 'uvicorn[standard]' python-multipart && uvicorn app:app --host 0.0.0.0 --port 8000"
|
||||
volumes:
|
||||
- /opt/hub/pdfs/data:/app
|
||||
- /opt/hub/pdfs/data/static:/static
|
||||
- /opt/hub/pdfs/files:/files
|
||||
networks:
|
||||
- hub
|
||||
|
||||
freqs:
|
||||
image: python:3.12-slim
|
||||
container_name: freqs
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "pip install --quiet fastapi 'uvicorn[standard]' python-multipart && uvicorn app:app --host 0.0.0.0 --port 8000"
|
||||
volumes:
|
||||
- /opt/hub/freqs/data:/app
|
||||
- /opt/hub/freqs/data:/data
|
||||
- /opt/hub/freqs/data/static:/static
|
||||
networks:
|
||||
- hub
|
||||
|
||||
inventory:
|
||||
image: python:3.12-slim
|
||||
container_name: inventory
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "pip install --quiet fastapi 'uvicorn[standard]' python-multipart && uvicorn app:app --host 0.0.0.0 --port 8000"
|
||||
volumes:
|
||||
- /opt/hub/inventory/data:/app
|
||||
- /opt/hub/inventory/data:/data
|
||||
- /opt/hub/inventory/data/static:/static
|
||||
networks:
|
||||
- hub
|
||||
|
||||
overlay:
|
||||
image: python:3.12-slim
|
||||
container_name: overlay
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "pip install --quiet fastapi 'uvicorn[standard]' && uvicorn app:app --host 0.0.0.0 --port 8000"
|
||||
volumes:
|
||||
- /opt/hub/overlay/data:/app
|
||||
- /opt/hub/overlay/data:/data
|
||||
- /opt/hub/overlay/data/static:/static
|
||||
networks:
|
||||
- hub
|
||||
|
||||
search:
|
||||
image: python:3.12-slim
|
||||
container_name: search
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "pip install --quiet fastapi 'uvicorn[standard]' httpx && uvicorn app:app --host 0.0.0.0 --port 8000"
|
||||
volumes:
|
||||
- /opt/hub/search/data:/app
|
||||
- /opt/hub/search/data/static:/static
|
||||
networks:
|
||||
- hub
|
||||
|
||||
zim-upload:
|
||||
image: python:3.12-slim
|
||||
container_name: zim-upload
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "pip install --quiet fastapi 'uvicorn[standard]' python-multipart && apt-get update -qq && apt-get install -y -qq curl docker.io && uvicorn app:app --host 0.0.0.0 --port 8000"
|
||||
volumes:
|
||||
- /opt/hub/zim-upload/data:/app
|
||||
- /opt/hub/kiwix/zims:/zims
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
networks:
|
||||
- hub
|
||||
|
||||
networks:
|
||||
hub:
|
||||
name: hub
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
CYAN='\033[0;36m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
MAPS_DIR=/opt/hub/maps/www
|
||||
mkdir -p "$MAPS_DIR"
|
||||
|
||||
if ! command -v pmtiles >/dev/null 2>&1; then
|
||||
echo -e "${CYAN}==> Installing pmtiles CLI...${NC}"
|
||||
cd /tmp
|
||||
PM_VER=1.30.3
|
||||
wget -q "https://github.com/protomaps/go-pmtiles/releases/download/v${PM_VER}/go-pmtiles_${PM_VER}_Linux_x86_64.tar.gz"
|
||||
tar -xzf "go-pmtiles_${PM_VER}_Linux_x86_64.tar.gz"
|
||||
mv pmtiles /usr/local/bin/
|
||||
rm -f "go-pmtiles_${PM_VER}_Linux_x86_64.tar.gz" README.txt LICENSE 2>/dev/null || true
|
||||
cd - >/dev/null
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}==> Finding latest Protomaps build...${NC}"
|
||||
LATEST_BUILD=$(curl -s https://maps.protomaps.com/builds/ | grep -oE '20[0-9]{6}\.pmtiles' | sort -u | tail -1)
|
||||
if [ -z "$LATEST_BUILD" ]; then
|
||||
echo -e "${YELLOW}Could not auto-detect latest build. Edit fetch-maps.sh and set BUILD manually.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using $LATEST_BUILD"
|
||||
|
||||
echo -e "${CYAN}==> Downloading North America basemap (~4 GB at zoom 12, this can take 10-30 min)...${NC}"
|
||||
pmtiles extract "https://build.protomaps.com/${LATEST_BUILD}" \
|
||||
"$MAPS_DIR/base.pmtiles" \
|
||||
--bbox=-168.0,14.5,-52.0,72.0 \
|
||||
--maxzoom=12
|
||||
|
||||
echo -e "${GREEN}==> Basemap done. Size:${NC} $(du -h "$MAPS_DIR/base.pmtiles" | cut -f1)"
|
||||
|
||||
echo
|
||||
read -p "Also download topographic terrain tiles? (~20 GB, 1-3 hr) [y/N]: " yn
|
||||
yn=${yn:-N}
|
||||
if [[ "$yn" =~ ^[Yy]$ ]]; then
|
||||
echo -e "${CYAN}==> Downloading Mapterhorn terrain (this is slow)...${NC}"
|
||||
pmtiles extract https://download.mapterhorn.com/planet.pmtiles \
|
||||
"$MAPS_DIR/terrain.pmtiles" \
|
||||
--bbox=-168.0,14.5,-52.0,72.0 \
|
||||
--maxzoom=12
|
||||
echo -e "${GREEN}==> Terrain done. Size:${NC} $(du -h "$MAPS_DIR/terrain.pmtiles" | cut -f1)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}Map download complete.${NC}"
|
||||
Binary file not shown.
@@ -0,0 +1,79 @@
|
||||
from fastapi import FastAPI, Request, Form, HTTPException
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
import os, hmac, hashlib, time, secrets
|
||||
|
||||
PASSWORD = os.environ.get("HUB_PASSWORD", "changeme")
|
||||
SECRET = os.environ.get("HUB_SECRET", secrets.token_hex(32))
|
||||
COOKIE_NAME = "darkelite_session"
|
||||
COOKIE_DAYS = 30
|
||||
COOKIE_TTL = COOKIE_DAYS * 86400
|
||||
|
||||
app = FastAPI(redirect_slashes=False)
|
||||
|
||||
def sign(payload: str) -> str:
|
||||
return hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
def make_token() -> str:
|
||||
exp = int(time.time()) + COOKIE_TTL
|
||||
payload = f"{exp}"
|
||||
sig = sign(payload)
|
||||
return f"{payload}.{sig}"
|
||||
|
||||
def verify_token(token: str) -> bool:
|
||||
if not token or "." not in token:
|
||||
return False
|
||||
try:
|
||||
payload, sig = token.rsplit(".", 1)
|
||||
if not hmac.compare_digest(sig, sign(payload)):
|
||||
return False
|
||||
exp = int(payload)
|
||||
return exp > int(time.time())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@app.get("/api/check")
|
||||
def check(request: Request):
|
||||
"""Caddy forward_auth hits this. 200=allowed, 401/redirect=needs login."""
|
||||
# LAN bypass
|
||||
# Use the FIRST X-Forwarded-For value (real client). Caddy adds this header.
|
||||
# Caddy passes real client IP in X-Real-IP via forward_auth header_up
|
||||
ip = request.headers.get("x-real-ip", "").strip()
|
||||
if not ip:
|
||||
xff = request.headers.get("x-forwarded-for", "")
|
||||
ip = xff.split(",")[0].strip()
|
||||
# Only treat actual LAN subnets as bypass - NOT docker internal 172.x
|
||||
if ip.startswith("10.0.0.") or ip.startswith("192.168.") or ip == "127.0.0.1":
|
||||
return JSONResponse({"ok": True, "via": "lan"})
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if verify_token(token):
|
||||
return JSONResponse({"ok": True, "via": "cookie"})
|
||||
# If this looks like a browser (accepts HTML), redirect to login
|
||||
accept = request.headers.get("accept", "")
|
||||
orig_uri = request.headers.get("x-forwarded-uri", "/")
|
||||
if "text/html" in accept:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=f"/login/?next={orig_uri}", status_code=302)
|
||||
raise HTTPException(401, "auth required")
|
||||
|
||||
@app.post("/api/login")
|
||||
def login(password: str = Form(...)):
|
||||
if not hmac.compare_digest(password, PASSWORD):
|
||||
raise HTTPException(401, "bad password")
|
||||
token = make_token()
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.set_cookie(
|
||||
COOKIE_NAME, token,
|
||||
max_age=COOKIE_TTL, httponly=True, samesite="lax", path="/"
|
||||
)
|
||||
return resp
|
||||
|
||||
@app.post("/api/logout")
|
||||
def logout():
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.delete_cookie(COOKIE_NAME, path="/")
|
||||
return resp
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def root():
|
||||
with open("/static/login.html") as f:
|
||||
return f.read()
|
||||
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Login - The Dark Elite</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
:root { --bg:#060708; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; --danger:#dc2626; }
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;height:100vh;overflow:hidden}
|
||||
body{display:flex;align-items:center;justify-content:center;background:radial-gradient(ellipse at center,rgba(34,211,238,0.04) 0%,transparent 70%),#060708}
|
||||
.box{width:380px;max-width:90vw;background:linear-gradient(135deg,rgba(18,20,24,0.95),rgba(10,11,13,0.95));border:1px solid var(--border);border-radius:8px;padding:2rem 2.25rem;box-shadow:0 10px 50px rgba(0,0,0,0.7),0 0 30px rgba(34,211,238,0.08)}
|
||||
.title{text-align:center;color:var(--accent);letter-spacing:0.3em;text-transform:uppercase;font-size:1.1rem;text-shadow:0 0 12px rgba(34,211,238,0.4);margin-bottom:0.25rem;font-weight:600}
|
||||
.sub{text-align:center;color:var(--muted);font-family:ui-monospace,monospace;font-size:0.7rem;letter-spacing:0.2em;text-transform:uppercase;margin-bottom:2rem}
|
||||
label{display:block;font-size:0.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.2em;margin-bottom:0.4rem}
|
||||
input[type=password]{width:100%;background:rgba(10,11,13,0.7);border:1px solid var(--border);color:var(--text);padding:0.75rem 0.9rem;border-radius:4px;font-size:0.95rem;font-family:ui-monospace,monospace;letter-spacing:0.1em}
|
||||
input[type=password]:focus{outline:none;border-color:var(--accent);box-shadow:0 0 15px rgba(34,211,238,0.15)}
|
||||
button{width:100%;margin-top:1.25rem;background:rgba(34,211,238,0.15);border:1px solid var(--accent);color:var(--accent);padding:0.75rem;border-radius:4px;font-size:0.85rem;cursor:pointer;letter-spacing:0.2em;text-transform:uppercase;font-family:ui-monospace,monospace}
|
||||
button:hover{background:rgba(34,211,238,0.25)}
|
||||
button:disabled{opacity:0.5;cursor:wait}
|
||||
.err{display:none;color:var(--danger);font-size:0.75rem;font-family:ui-monospace,monospace;text-align:center;margin-top:0.85rem;letter-spacing:0.1em;text-transform:uppercase}
|
||||
.err.show{display:block}
|
||||
.footer{text-align:center;color:var(--muted);font-family:ui-monospace,monospace;font-size:0.6rem;letter-spacing:0.2em;text-transform:uppercase;margin-top:1.5rem;opacity:0.6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div class="title">The Dark Elite</div>
|
||||
<div class="sub">// Authorization Required //</div>
|
||||
<form id="loginForm">
|
||||
<label>Passphrase</label>
|
||||
<input type="password" id="pw" autofocus autocomplete="current-password">
|
||||
<button type="submit" id="submitBtn">Authenticate</button>
|
||||
<div class="err" id="err">Invalid passphrase</div>
|
||||
</form>
|
||||
<div class="footer">Node: thedarkelite</div>
|
||||
</div>
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const next = params.get("next") || "/";
|
||||
|
||||
document.getElementById("loginForm").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const pw = document.getElementById("pw").value;
|
||||
const btn = document.getElementById("submitBtn");
|
||||
const err = document.getElementById("err");
|
||||
err.classList.remove("show");
|
||||
btn.disabled = true; btn.textContent = "Verifying...";
|
||||
const fd = new FormData(); fd.append("password", pw);
|
||||
try {
|
||||
const r = await fetch("/auth/api/login", { method: "POST", body: fd });
|
||||
if (r.ok) {
|
||||
btn.textContent = "Authorized";
|
||||
setTimeout(() => location.href = next, 300);
|
||||
} else {
|
||||
err.classList.add("show");
|
||||
btn.disabled = false; btn.textContent = "Authenticate";
|
||||
document.getElementById("pw").select();
|
||||
}
|
||||
} catch(e) {
|
||||
err.textContent = "Connection error";
|
||||
err.classList.add("show");
|
||||
btn.disabled = false; btn.textContent = "Authenticate";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
auto_https off
|
||||
servers {
|
||||
max_header_size 8KB
|
||||
trusted_proxies static private_ranges
|
||||
client_ip_headers X-Forwarded-For X-Real-IP
|
||||
}
|
||||
}
|
||||
|
||||
:80 {
|
||||
request_body {
|
||||
max_size 50GB
|
||||
}
|
||||
|
||||
# ===== UNGUARDED PATHS (login/auth/logout) =====
|
||||
handle_path /auth/* {
|
||||
reverse_proxy auth:8000
|
||||
}
|
||||
|
||||
handle /login {
|
||||
rewrite * /
|
||||
reverse_proxy auth:8000
|
||||
}
|
||||
|
||||
handle_path /login/* {
|
||||
reverse_proxy auth:8000
|
||||
}
|
||||
|
||||
handle /logout {
|
||||
reverse_proxy auth:8000/api/logout
|
||||
redir /login 302
|
||||
}
|
||||
|
||||
# ===== GUARDED ROUTES =====
|
||||
# forward_auth as a route-level directive: applied to all subsequent handles
|
||||
route {
|
||||
forward_auth auth:8000 {
|
||||
uri /api/check
|
||||
header_up X-Real-IP {client_ip}
|
||||
header_up X-Forwarded-Uri {uri}
|
||||
copy_headers Cookie
|
||||
}
|
||||
|
||||
handle_path /maps/* {
|
||||
root * /srv/maps
|
||||
file_server
|
||||
}
|
||||
handle_path /weather/* {
|
||||
root * /srv/weather
|
||||
file_server
|
||||
}
|
||||
handle_path /garden/* {
|
||||
root * /srv/garden
|
||||
file_server
|
||||
}
|
||||
|
||||
@kiwixApi path /library/viewer* /library/content* /library/catalog* /library/skin* /library/search* /library/suggest* /library/raw* /library/ROOT*
|
||||
handle @kiwixApi {
|
||||
reverse_proxy kiwix:8080
|
||||
}
|
||||
|
||||
handle_path /library-admin/* {
|
||||
reverse_proxy zim-upload:8000
|
||||
}
|
||||
|
||||
handle_path /library/* {
|
||||
root * /srv/library
|
||||
file_server
|
||||
try_files {path} /index.html
|
||||
}
|
||||
handle /library {
|
||||
redir /library/ 301
|
||||
}
|
||||
|
||||
@freqs path /freqs /freqs/*
|
||||
handle @freqs {
|
||||
uri strip_prefix /freqs
|
||||
reverse_proxy freqs:8000
|
||||
}
|
||||
|
||||
@inventory path /inventory /inventory/*
|
||||
handle @inventory {
|
||||
uri strip_prefix /inventory
|
||||
reverse_proxy inventory:8000
|
||||
}
|
||||
|
||||
@overlay path /overlay /overlay/*
|
||||
handle @overlay {
|
||||
uri strip_prefix /overlay
|
||||
reverse_proxy overlay:8000
|
||||
}
|
||||
|
||||
@search path /search /search/*
|
||||
handle @search {
|
||||
uri strip_prefix /search
|
||||
reverse_proxy search:8000
|
||||
}
|
||||
|
||||
handle_path /pdfs/* {
|
||||
reverse_proxy pdfs:8000
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy homepage:3000
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,62 @@
|
||||
from fastapi import FastAPI, HTTPException, Form
|
||||
from fastapi.responses import HTMLResponse
|
||||
import os, json, uuid
|
||||
from datetime import datetime
|
||||
|
||||
DATA = "/data/freqs.json"
|
||||
app = FastAPI(redirect_slashes=False)
|
||||
|
||||
def load():
|
||||
if os.path.exists(DATA):
|
||||
with open(DATA) as f: return json.load(f)
|
||||
return {}
|
||||
|
||||
def save(d):
|
||||
with open(DATA, "w") as f: json.dump(d, f, indent=2)
|
||||
|
||||
@app.get("/api/list")
|
||||
def list_freqs():
|
||||
items = load()
|
||||
return sorted(
|
||||
[{"id": k, **v} for k, v in items.items()],
|
||||
key=lambda x: (x.get("band", ""), float(x.get("freq", 0) or 0))
|
||||
)
|
||||
|
||||
@app.post("/api/add")
|
||||
def add(freq: str = Form(""), name: str = Form(""), mode: str = Form(""),
|
||||
band: str = Form(""), tone: str = Form(""), offset: str = Form(""),
|
||||
description: str = Form(""), tag: str = Form("")):
|
||||
items = load()
|
||||
fid = uuid.uuid4().hex[:12]
|
||||
items[fid] = {
|
||||
"freq": freq, "name": name, "mode": mode, "band": band,
|
||||
"tone": tone, "offset": offset, "description": description,
|
||||
"tag": tag, "added": datetime.utcnow().isoformat()
|
||||
}
|
||||
save(items)
|
||||
return {"id": fid}
|
||||
|
||||
@app.patch("/api/update/{fid}")
|
||||
def update(fid: str, freq: str = Form(None), name: str = Form(None),
|
||||
mode: str = Form(None), band: str = Form(None), tone: str = Form(None),
|
||||
offset: str = Form(None), description: str = Form(None), tag: str = Form(None)):
|
||||
items = load()
|
||||
if fid not in items: raise HTTPException(404)
|
||||
for k, v in {"freq": freq, "name": name, "mode": mode, "band": band,
|
||||
"tone": tone, "offset": offset, "description": description, "tag": tag}.items():
|
||||
if v is not None: items[fid][k] = v
|
||||
save(items)
|
||||
return {"ok": True}
|
||||
|
||||
@app.delete("/api/delete/{fid}")
|
||||
def delete(fid: str):
|
||||
items = load()
|
||||
if fid not in items: raise HTTPException(404)
|
||||
del items[fid]
|
||||
save(items)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def root():
|
||||
with open("/static/index.html") as f:
|
||||
return f.read()
|
||||
@@ -0,0 +1,409 @@
|
||||
{
|
||||
"dca821d3b8ce": {
|
||||
"freq": "162.400",
|
||||
"name": "NOAA Weather Radio - Fort Worth (Primary)",
|
||||
"mode": "FM",
|
||||
"band": "NOAA",
|
||||
"tone": "",
|
||||
"offset": "N/A",
|
||||
"description": "Primary NOAA WX freq for Ellis County. Channel 1.",
|
||||
"tag": "noaa,weather",
|
||||
"added": "2026-06-30T05:10:57.809929"
|
||||
},
|
||||
"268c6f32a65e": {
|
||||
"freq": "162.550",
|
||||
"name": "NOAA Weather Radio - Fort Worth (Backup)",
|
||||
"mode": "FM",
|
||||
"band": "NOAA",
|
||||
"tone": "",
|
||||
"offset": "N/A",
|
||||
"description": "Secondary NOAA WX freq for Ellis County. Channel 7.",
|
||||
"tag": "noaa,weather",
|
||||
"added": "2026-06-30T05:10:57.817404"
|
||||
},
|
||||
"040dd4d93450": {
|
||||
"freq": "162.525",
|
||||
"name": "NOAA Weather Radio - Fort Worth (Tertiary)",
|
||||
"mode": "FM",
|
||||
"band": "NOAA",
|
||||
"tone": "",
|
||||
"offset": "N/A",
|
||||
"description": "Tertiary NOAA WX freq for Ellis County. Channel 6.",
|
||||
"tag": "noaa,weather",
|
||||
"added": "2026-06-30T05:10:57.825125"
|
||||
},
|
||||
"5273ebab24fd": {
|
||||
"freq": "145.410",
|
||||
"name": "WD5DDH SKYWARN - Midlothian",
|
||||
"mode": "FM",
|
||||
"band": "VHF-2m",
|
||||
"tone": "110.9 PL",
|
||||
"offset": "-0.6",
|
||||
"description": "Ellis County ARC SKYWARN repeater. Severe weather net.",
|
||||
"tag": "repeater,skywarn",
|
||||
"added": "2026-06-30T05:10:57.832547"
|
||||
},
|
||||
"7d283c84981d": {
|
||||
"freq": "145.410",
|
||||
"name": "WD5DDH SKYWARN - Waxahachie",
|
||||
"mode": "FM",
|
||||
"band": "VHF-2m",
|
||||
"tone": "162.2 PL",
|
||||
"offset": "-0.6",
|
||||
"description": "Ellis County ARC SKYWARN repeater. Severe weather net.",
|
||||
"tag": "repeater,skywarn",
|
||||
"added": "2026-06-30T05:10:57.840343"
|
||||
},
|
||||
"89f140dc63b9": {
|
||||
"freq": "145.410",
|
||||
"name": "WD5DDH SKYWARN - Ennis",
|
||||
"mode": "FM",
|
||||
"band": "VHF-2m",
|
||||
"tone": "131.8 PL",
|
||||
"offset": "-0.6",
|
||||
"description": "Ellis County ARC repeater - Ennis side, severe weather net",
|
||||
"tag": "repeater,skywarn",
|
||||
"added": "2026-06-30T05:19:55.454888"
|
||||
},
|
||||
"4a88dc83e6f1": {
|
||||
"freq": "441.650",
|
||||
"name": "WD5DDH UHF - Waxahachie",
|
||||
"mode": "FM",
|
||||
"band": "UHF-70cm",
|
||||
"tone": "110.9 PL",
|
||||
"offset": "+5",
|
||||
"description": "Ellis County ARC UHF repeater, Waxahachie area",
|
||||
"tag": "repeater,club",
|
||||
"added": "2026-06-30T05:19:55.468042"
|
||||
},
|
||||
"a9b79a80d1bf": {
|
||||
"freq": "442.525",
|
||||
"name": "WD5DDH - Italy",
|
||||
"mode": "FM",
|
||||
"band": "UHF-70cm",
|
||||
"tone": "88.5 PL",
|
||||
"offset": "+5",
|
||||
"description": "Ellis County ARC UHF repeater, Italy water tower",
|
||||
"tag": "repeater,club",
|
||||
"added": "2026-06-30T05:19:55.479919"
|
||||
},
|
||||
"205864b19829": {
|
||||
"freq": "145.250",
|
||||
"name": "KD5OL - Palmer",
|
||||
"mode": "FM",
|
||||
"band": "VHF-2m",
|
||||
"tone": "100.0 PL",
|
||||
"offset": "-0.6",
|
||||
"description": "Yaesu Fusion / WIRES-X, Palmer TX",
|
||||
"tag": "repeater,fusion",
|
||||
"added": "2026-06-30T05:19:55.491816"
|
||||
},
|
||||
"3380bd95ef1d": {
|
||||
"freq": "146.880",
|
||||
"name": "W5FC DARC - Dallas",
|
||||
"mode": "FM",
|
||||
"band": "VHF-2m",
|
||||
"tone": "110.9 PL",
|
||||
"offset": "-0.6",
|
||||
"description": "Dallas Amateur Radio Club - most active 2m in DFW",
|
||||
"tag": "repeater,dfw",
|
||||
"added": "2026-06-30T05:19:55.501171"
|
||||
},
|
||||
"13a4ed10d6c6": {
|
||||
"freq": "442.425",
|
||||
"name": "W5FC Fusion - Dallas",
|
||||
"mode": "FM",
|
||||
"band": "UHF-70cm",
|
||||
"tone": "110.9 PL",
|
||||
"offset": "+5",
|
||||
"description": "DARC Yaesu Fusion AMS, WIRES-X linked",
|
||||
"tag": "repeater,fusion",
|
||||
"added": "2026-06-30T05:19:55.511745"
|
||||
},
|
||||
"e9889d52ba8b": {
|
||||
"freq": "145.110",
|
||||
"name": "K5FTW - Fort Worth",
|
||||
"mode": "FM",
|
||||
"band": "VHF-2m",
|
||||
"tone": "110.9 PL",
|
||||
"offset": "-0.6",
|
||||
"description": "Fort Worth area - very popular, wide coverage",
|
||||
"tag": "repeater,dfw",
|
||||
"added": "2026-06-30T05:19:55.524403"
|
||||
},
|
||||
"c70278ca1638": {
|
||||
"freq": "146.940",
|
||||
"name": "K5FTW - Fort Worth RACES",
|
||||
"mode": "FM",
|
||||
"band": "VHF-2m",
|
||||
"tone": "110.9 PL",
|
||||
"offset": "-0.6",
|
||||
"description": "Tarrant County RACES nightly net",
|
||||
"tag": "repeater,races",
|
||||
"added": "2026-06-30T05:19:55.536858"
|
||||
},
|
||||
"1d31637df8e1": {
|
||||
"freq": "146.720",
|
||||
"name": "WA5CKF - Irving",
|
||||
"mode": "FM",
|
||||
"band": "VHF-2m",
|
||||
"tone": "110.9 PL",
|
||||
"offset": "-0.6",
|
||||
"description": "DFW Traffic Nets nightly, Irving",
|
||||
"tag": "repeater,nets",
|
||||
"added": "2026-06-30T05:19:55.544339"
|
||||
},
|
||||
"aca9862288c9": {
|
||||
"freq": "147.120",
|
||||
"name": "K5RWK - Richardson",
|
||||
"mode": "FM",
|
||||
"band": "VHF-2m",
|
||||
"tone": "110.9 PL",
|
||||
"offset": "+0.6",
|
||||
"description": "Richardson Wireless Klub AllStar linked",
|
||||
"tag": "repeater,allstar",
|
||||
"added": "2026-06-30T05:19:55.551990"
|
||||
},
|
||||
"51c0947ec40f": {
|
||||
"freq": "462.650",
|
||||
"name": "WRXB290 - Waxahachie GMRS",
|
||||
"mode": "FM",
|
||||
"band": "GMRS",
|
||||
"tone": "131.8 PL",
|
||||
"offset": "+5",
|
||||
"description": "Ellis County REACT GMRS - east of Waxahachie",
|
||||
"tag": "gmrs,repeater",
|
||||
"added": "2026-06-30T05:19:55.564920"
|
||||
},
|
||||
"32dd8b2b6d67": {
|
||||
"freq": "462.625",
|
||||
"name": "WRVB840 - Nash/Forreston GMRS",
|
||||
"mode": "FM",
|
||||
"band": "GMRS",
|
||||
"tone": "131.8 PL",
|
||||
"offset": "+5",
|
||||
"description": "Ellis County REACT GMRS - Nash/Forreston",
|
||||
"tag": "gmrs,repeater",
|
||||
"added": "2026-06-30T05:19:55.577459"
|
||||
},
|
||||
"1f69a5691fba": {
|
||||
"freq": "462.700",
|
||||
"name": "WRWT247 - Palmer GMRS",
|
||||
"mode": "FM",
|
||||
"band": "GMRS",
|
||||
"tone": "131.8 PL",
|
||||
"offset": "+5",
|
||||
"description": "Ellis County REACT GMRS - Palmer area",
|
||||
"tag": "gmrs,repeater",
|
||||
"added": "2026-06-30T05:19:55.585068"
|
||||
},
|
||||
"a1542dbbe73c": {
|
||||
"freq": "462.675",
|
||||
"name": "GMRS Emergency / Travel Calling",
|
||||
"mode": "FM",
|
||||
"band": "GMRS",
|
||||
"tone": "141.3 PL",
|
||||
"offset": "",
|
||||
"description": "GMRS channel 20 - standard emergency/travel calling freq",
|
||||
"tag": "calling,emergency",
|
||||
"added": "2026-06-30T05:19:55.592761"
|
||||
},
|
||||
"b48d9242feb2": {
|
||||
"freq": "151.940",
|
||||
"name": "MURS Channel 3",
|
||||
"mode": "FM",
|
||||
"band": "MURS",
|
||||
"tone": "",
|
||||
"offset": "",
|
||||
"description": "MURS no-license-required VHF simplex",
|
||||
"tag": "simplex,unlicensed",
|
||||
"added": "2026-06-30T05:19:55.600318"
|
||||
},
|
||||
"582cd42a42bd": {
|
||||
"freq": "154.600",
|
||||
"name": "MURS Channel 5 (Blue Dot)",
|
||||
"mode": "FM",
|
||||
"band": "MURS",
|
||||
"tone": "",
|
||||
"offset": "",
|
||||
"description": "MURS Blue Dot - commonly used Walmart channel",
|
||||
"tag": "simplex,unlicensed",
|
||||
"added": "2026-06-30T05:19:55.608153"
|
||||
},
|
||||
"076de1dd8e31": {
|
||||
"freq": "27.065",
|
||||
"name": "CB Channel 9 - Emergency",
|
||||
"mode": "AM",
|
||||
"band": "CB",
|
||||
"tone": "",
|
||||
"offset": "",
|
||||
"description": "CB emergency / motorist assistance channel",
|
||||
"tag": "calling,emergency",
|
||||
"added": "2026-06-30T05:19:55.615832"
|
||||
},
|
||||
"d1f88010eaed": {
|
||||
"freq": "27.185",
|
||||
"name": "CB Channel 19 - Truckers",
|
||||
"mode": "AM",
|
||||
"band": "CB",
|
||||
"tone": "",
|
||||
"offset": "",
|
||||
"description": "CB truckers channel - highway info",
|
||||
"tag": "simplex,trucker",
|
||||
"added": "2026-06-30T05:19:55.623960"
|
||||
},
|
||||
"135625984e19": {
|
||||
"freq": "155.580",
|
||||
"name": "Ellis County Fire Dispatch",
|
||||
"mode": "FM",
|
||||
"band": "Public Safety",
|
||||
"tone": "77.0 PL",
|
||||
"offset": "",
|
||||
"description": "Countywide fire dispatch frequency",
|
||||
"tag": "scanner,fire",
|
||||
"added": "2026-06-30T05:19:55.631683"
|
||||
},
|
||||
"0cf09960a8dc": {
|
||||
"freq": "159.3675",
|
||||
"name": "Ellis County EMS (AMR)",
|
||||
"mode": "FM",
|
||||
"band": "Public Safety",
|
||||
"tone": "532 DPL",
|
||||
"offset": "",
|
||||
"description": "Ellis County EMS dispatch (operated by AMR)",
|
||||
"tag": "scanner,ems",
|
||||
"added": "2026-06-30T05:19:55.643258"
|
||||
},
|
||||
"b12945bfdc82": {
|
||||
"freq": "155.040",
|
||||
"name": "Ennis Police Dispatch",
|
||||
"mode": "FM",
|
||||
"band": "Public Safety",
|
||||
"tone": "466 DPL",
|
||||
"offset": "",
|
||||
"description": "Ennis PD dispatch",
|
||||
"tag": "scanner,police",
|
||||
"added": "2026-06-30T05:19:55.651695"
|
||||
},
|
||||
"c2e37fee36b4": {
|
||||
"freq": "155.310",
|
||||
"name": "Glenn Heights Police",
|
||||
"mode": "FM",
|
||||
"band": "Public Safety",
|
||||
"tone": "151.4 PL",
|
||||
"offset": "",
|
||||
"description": "Glenn Heights PD analog dispatch",
|
||||
"tag": "scanner,police",
|
||||
"added": "2026-06-30T05:19:55.659366"
|
||||
},
|
||||
"9bb2eaa50fbd": {
|
||||
"freq": "155.880",
|
||||
"name": "Ferris Police/Fire",
|
||||
"mode": "FM",
|
||||
"band": "Public Safety",
|
||||
"tone": "94.8 PL",
|
||||
"offset": "",
|
||||
"description": "Ferris combined police/fire dispatch",
|
||||
"tag": "scanner,multi",
|
||||
"added": "2026-06-30T05:19:55.667174"
|
||||
},
|
||||
"a9ea8248551c": {
|
||||
"freq": "151.0175",
|
||||
"name": "Palmer Police",
|
||||
"mode": "FM",
|
||||
"band": "Public Safety",
|
||||
"tone": "343 DPL",
|
||||
"offset": "",
|
||||
"description": "Palmer PD dispatch",
|
||||
"tag": "scanner,police",
|
||||
"added": "2026-06-30T05:19:55.675102"
|
||||
},
|
||||
"b808d11a5903": {
|
||||
"freq": "151.460",
|
||||
"name": "Midlothian Fire Dispatch",
|
||||
"mode": "FM",
|
||||
"band": "Public Safety",
|
||||
"tone": "",
|
||||
"offset": "",
|
||||
"description": "Midlothian Fire (legacy VHF channel)",
|
||||
"tag": "scanner,fire",
|
||||
"added": "2026-06-30T05:19:55.682738"
|
||||
},
|
||||
"73434d455c05": {
|
||||
"freq": "155.790",
|
||||
"name": "Waxahachie Police (legacy)",
|
||||
"mode": "FM",
|
||||
"band": "Public Safety",
|
||||
"tone": "",
|
||||
"offset": "",
|
||||
"description": "Waxahachie PD legacy VHF dispatch",
|
||||
"tag": "scanner,police",
|
||||
"added": "2026-06-30T05:19:55.690597"
|
||||
},
|
||||
"3e03b296356d": {
|
||||
"freq": "462.675",
|
||||
"name": "FRS Channel 1 (call)",
|
||||
"mode": "FM",
|
||||
"band": "FRS",
|
||||
"tone": "",
|
||||
"offset": "",
|
||||
"description": "FRS channel 1 - common family/group use",
|
||||
"tag": "simplex,family",
|
||||
"added": "2026-06-30T05:19:55.698509"
|
||||
},
|
||||
"3404401304c4": {
|
||||
"freq": "446.000",
|
||||
"name": "2m Calling Frequency",
|
||||
"mode": "FM",
|
||||
"band": "UHF-70cm",
|
||||
"tone": "",
|
||||
"offset": "",
|
||||
"description": "National 70cm FM simplex calling frequency",
|
||||
"tag": "simplex,calling",
|
||||
"added": "2026-06-30T05:19:55.706494"
|
||||
},
|
||||
"bc9a34941d2c": {
|
||||
"freq": "146.520",
|
||||
"name": "2m Calling Frequency",
|
||||
"mode": "FM",
|
||||
"band": "VHF-2m",
|
||||
"tone": "",
|
||||
"offset": "",
|
||||
"description": "National 2m FM simplex calling frequency",
|
||||
"tag": "simplex,calling",
|
||||
"added": "2026-06-30T05:19:55.714369"
|
||||
},
|
||||
"7b59114a2767": {
|
||||
"freq": "14.300",
|
||||
"name": "Maritime Mobile Service Net",
|
||||
"mode": "SSB-USB",
|
||||
"band": "HF-20m",
|
||||
"tone": "",
|
||||
"offset": "",
|
||||
"description": "Daily maritime/emergency traffic net, USB",
|
||||
"tag": "hf,emergency",
|
||||
"added": "2026-06-30T05:19:55.722718"
|
||||
},
|
||||
"b82361198ff7": {
|
||||
"freq": "7.268",
|
||||
"name": "HF SATERN Net",
|
||||
"mode": "SSB-LSB",
|
||||
"band": "HF-40m",
|
||||
"tone": "",
|
||||
"offset": "",
|
||||
"description": "Salvation Army Team Emergency Radio Network, LSB",
|
||||
"tag": "hf,emergency",
|
||||
"added": "2026-06-30T05:19:55.730777"
|
||||
},
|
||||
"b836211376cd": {
|
||||
"freq": "904.625",
|
||||
"name": "Meshtastic LongFast (US)",
|
||||
"mode": "LoRa",
|
||||
"band": "Mesh",
|
||||
"tone": "",
|
||||
"offset": "",
|
||||
"description": "Default Meshtastic channel for North America",
|
||||
"tag": "mesh,data",
|
||||
"added": "2026-06-30T05:19:55.739037"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Frequencies - The Dark Elite</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
:root { --bg:#060708; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; --danger:#dc2626; }
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;min-height:100vh}
|
||||
.container{max-width:1300px;margin:0 auto;padding:2rem}
|
||||
header{display:flex;justify-content:space-between;align-items:center;padding-bottom:1.5rem;border-bottom:1px solid var(--border);margin-bottom:2rem}
|
||||
header h1{margin:0;font-size:1.5rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent);text-shadow:0 0 12px rgba(34,211,238,0.4)}
|
||||
header a{color:var(--muted);text-decoration:none;font-size:0.85rem}
|
||||
header a:hover{color:var(--accent)}
|
||||
.section-title{font-size:0.75rem;letter-spacing:0.25em;text-transform:uppercase;color:var(--accent);border-left:3px solid var(--accent);padding-left:0.75rem;margin:2rem 0 1rem 0;display:flex;justify-content:space-between;align-items:center}
|
||||
.controls{display:flex;gap:0.5rem;flex-wrap:wrap}
|
||||
.controls input,.controls select{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.4rem 0.7rem;border-radius:4px;font-size:0.8rem;font-family:ui-monospace,monospace}
|
||||
.controls input:focus,.controls select:focus{outline:none;border-color:var(--accent)}
|
||||
.add-form{background:linear-gradient(135deg,rgba(18,20,24,0.7),rgba(10,11,13,0.85));border:1px solid var(--border);border-radius:8px;padding:1.25rem;display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:0.75rem;margin-bottom:2rem}
|
||||
.add-form input,.add-form select{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.55rem 0.7rem;border-radius:4px;font-size:0.85rem;font-family:ui-monospace,monospace}
|
||||
.add-form input:focus,.add-form select:focus{outline:none;border-color:var(--accent)}
|
||||
.add-form .full{grid-column:1/-1}
|
||||
.add-form button{background:rgba(34,211,238,0.15);border:1px solid var(--accent);color:var(--accent);padding:0.55rem;border-radius:4px;font-size:0.8rem;cursor:pointer;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace}
|
||||
.add-form button:hover{background:rgba(34,211,238,0.25)}
|
||||
table{width:100%;border-collapse:collapse;font-size:0.85rem;font-family:ui-monospace,monospace}
|
||||
th{text-align:left;color:var(--muted);font-weight:400;text-transform:uppercase;letter-spacing:0.1em;font-size:0.7rem;padding:0.7rem 0.5rem;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--bg);cursor:pointer;user-select:none}
|
||||
th:hover{color:var(--accent)}
|
||||
td{padding:0.7rem 0.5rem;border-bottom:1px solid rgba(80,90,100,0.1)}
|
||||
tr:hover td{background:rgba(34,211,238,0.04)}
|
||||
td.freq{color:var(--accent);font-weight:600}
|
||||
td.editable{cursor:text}
|
||||
td.editable:hover{background:rgba(34,211,238,0.08)}
|
||||
td input{background:rgba(10,11,13,0.9);border:1px solid var(--accent);color:var(--accent);padding:0.2rem 0.4rem;border-radius:3px;font:inherit;width:100%;outline:none}
|
||||
.del{color:var(--muted);cursor:pointer;font-size:0.7rem;text-transform:uppercase;letter-spacing:0.1em}
|
||||
.del:hover{color:var(--danger)}
|
||||
.empty{color:var(--muted);text-align:center;padding:2rem;font-style:italic}
|
||||
.toast{position:fixed;bottom:2rem;right:2rem;background:rgba(18,20,24,0.95);border:1px solid var(--accent);color:var(--accent);padding:0.8rem 1.2rem;border-radius:4px;font-family:ui-monospace,monospace;font-size:0.85rem;z-index:100;display:none}
|
||||
.tag-pill{display:inline-block;background:rgba(34,211,238,0.12);border:1px solid rgba(34,211,238,0.3);color:rgb(165,235,247);font-size:0.65rem;padding:0.1rem 0.4rem;border-radius:3px;text-transform:uppercase;letter-spacing:0.1em}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div><h1>Frequencies</h1></div>
|
||||
<a href="/" style="color:#9ca3af;text-decoration:none;font-size:0.85rem">← Hub</a>
|
||||
</header>
|
||||
|
||||
<div class="section-title">Add Frequency</div>
|
||||
<form class="add-form" id="addForm">
|
||||
<input name="freq" placeholder="Freq (MHz)" required>
|
||||
<input name="name" placeholder="Name / Callsign">
|
||||
<select name="band">
|
||||
<option value="">Band</option>
|
||||
<option>HF-160m</option><option>HF-80m</option><option>HF-40m</option><option>HF-20m</option><option>HF-15m</option><option>HF-10m</option>
|
||||
<option>VHF-6m</option><option>VHF-2m</option><option>UHF-70cm</option><option>UHF-33cm</option><option>UHF-23cm</option>
|
||||
<option>GMRS</option><option>FRS</option><option>MURS</option><option>CB</option>
|
||||
<option>NOAA</option><option>Marine</option><option>Aircraft</option>
|
||||
<option>Public Safety</option><option>Business</option><option>Mil-Air</option>
|
||||
<option>Mesh</option><option>Other</option>
|
||||
</select>
|
||||
<select name="mode">
|
||||
<option value="">Mode</option>
|
||||
<option>FM</option><option>AM</option><option>SSB-USB</option><option>SSB-LSB</option>
|
||||
<option>CW</option><option>DMR</option><option>D-STAR</option><option>YSF</option>
|
||||
<option>P25</option><option>NXDN</option><option>DSTAR</option><option>FT8</option>
|
||||
<option>FT4</option><option>PSK31</option><option>RTTY</option><option>APRS</option>
|
||||
<option>Packet</option><option>LoRa</option>
|
||||
</select>
|
||||
<input name="tone" placeholder="Tone (CTCSS/DCS)">
|
||||
<input name="offset" placeholder="Offset (+/- MHz)">
|
||||
<input name="tag" placeholder="Tag (repeater, simplex, net)">
|
||||
<input name="description" class="full" placeholder="Description / notes">
|
||||
<button type="submit" class="full">+ Add Frequency</button>
|
||||
</form>
|
||||
|
||||
<div class="section-title">
|
||||
Saved Frequencies
|
||||
<div class="controls">
|
||||
<select id="filterBand"><option value="">All Bands</option></select>
|
||||
<input type="text" id="search" placeholder="Filter...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="freq">Freq</th>
|
||||
<th data-sort="name">Name</th>
|
||||
<th data-sort="band">Band</th>
|
||||
<th data-sort="mode">Mode</th>
|
||||
<th data-sort="tone">Tone</th>
|
||||
<th data-sort="offset">Offset</th>
|
||||
<th data-sort="tag">Tag</th>
|
||||
<th data-sort="description">Description</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rows"><tr><td colspan="9" class="empty">Loading...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script>
|
||||
let all=[]; let sortKey="band"; let sortDir=1;
|
||||
const FIELDS=["freq","name","band","mode","tone","offset","tag","description"];
|
||||
|
||||
function toast(m){const t=document.getElementById("toast");t.textContent=m;t.style.display="block";setTimeout(()=>t.style.display="none",2000);}
|
||||
|
||||
async function load(){
|
||||
const r=await fetch("/freqs/api/list");
|
||||
all=await r.json();
|
||||
populateBands();
|
||||
render();
|
||||
}
|
||||
|
||||
function populateBands(){
|
||||
const bands=[...new Set(all.map(x=>x.band).filter(Boolean))].sort();
|
||||
const sel=document.getElementById("filterBand");
|
||||
const cur=sel.value;
|
||||
sel.innerHTML='<option value="">All Bands</option>'+bands.map(b=>'<option>'+b+'</option>').join("");
|
||||
sel.value=cur;
|
||||
}
|
||||
|
||||
function render(){
|
||||
const q=document.getElementById("search").value.toLowerCase();
|
||||
const band=document.getElementById("filterBand").value;
|
||||
let items=all.filter(x=>{
|
||||
if(band && x.band!==band)return false;
|
||||
if(q){const s=FIELDS.map(f=>(x[f]||"")).join(" ").toLowerCase();if(!s.includes(q))return false;}
|
||||
return true;
|
||||
});
|
||||
items.sort((a,b)=>{
|
||||
const va=(a[sortKey]||""), vb=(b[sortKey]||"");
|
||||
if(sortKey==="freq"){return ((parseFloat(va)||0)-(parseFloat(vb)||0))*sortDir;}
|
||||
return String(va).localeCompare(String(vb))*sortDir;
|
||||
});
|
||||
const tbody=document.getElementById("rows");
|
||||
if(!items.length){tbody.innerHTML='<tr><td colspan="9" class="empty">No frequencies yet. Add one above.</td></tr>';return;}
|
||||
tbody.innerHTML=items.map(x=>'<tr data-id="'+x.id+'">'+
|
||||
'<td class="freq editable" data-f="freq">'+(x.freq||"")+'</td>'+
|
||||
'<td class="editable" data-f="name">'+(x.name||"")+'</td>'+
|
||||
'<td class="editable" data-f="band">'+(x.band?'<span class="tag-pill">'+x.band+'</span>':"")+'</td>'+
|
||||
'<td class="editable" data-f="mode">'+(x.mode||"")+'</td>'+
|
||||
'<td class="editable" data-f="tone">'+(x.tone||"")+'</td>'+
|
||||
'<td class="editable" data-f="offset">'+(x.offset||"")+'</td>'+
|
||||
'<td class="editable" data-f="tag">'+(x.tag||"")+'</td>'+
|
||||
'<td class="editable" data-f="description" style="max-width:280px">'+(x.description||"")+'</td>'+
|
||||
'<td><span class="del" data-id="'+x.id+'">Del</span></td>'+
|
||||
'</tr>').join("");
|
||||
}
|
||||
|
||||
document.getElementById("addForm").addEventListener("submit",async e=>{
|
||||
e.preventDefault();
|
||||
const fd=new FormData(e.target);
|
||||
const r=await fetch("/freqs/api/add",{method:"POST",body:fd});
|
||||
if(r.ok){toast("Added");e.target.reset();load();}
|
||||
else toast("Failed");
|
||||
});
|
||||
|
||||
document.querySelectorAll("th[data-sort]").forEach(th=>{
|
||||
th.addEventListener("click",()=>{
|
||||
const k=th.dataset.sort;
|
||||
if(sortKey===k)sortDir*=-1; else {sortKey=k;sortDir=1;}
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("search").addEventListener("input",render);
|
||||
document.getElementById("filterBand").addEventListener("change",render);
|
||||
|
||||
document.getElementById("rows").addEventListener("click",async e=>{
|
||||
if(e.target.classList.contains("del")){
|
||||
const id=e.target.dataset.id;
|
||||
if(!confirm("Delete this frequency?"))return;
|
||||
await fetch("/freqs/api/delete/"+id,{method:"DELETE"});
|
||||
toast("Deleted");load();
|
||||
return;
|
||||
}
|
||||
const td=e.target.closest("td.editable");
|
||||
if(!td || td.querySelector("input"))return;
|
||||
const id=td.parentElement.dataset.id;
|
||||
const field=td.dataset.f;
|
||||
const cur=field==="band"?(td.textContent||"").trim():td.textContent;
|
||||
td.innerHTML='<input type="text" value="'+cur.replace(/"/g,""")+'">';
|
||||
const inp=td.querySelector("input"); inp.focus(); inp.select();
|
||||
const save=async()=>{
|
||||
const val=inp.value;
|
||||
const fd=new FormData(); fd.append(field,val);
|
||||
await fetch("/freqs/api/update/"+id,{method:"PATCH",body:fd});
|
||||
toast("Updated");load();
|
||||
};
|
||||
inp.addEventListener("blur",save);
|
||||
inp.addEventListener("keydown",ev=>{if(ev.key==="Enter")inp.blur();if(ev.key==="Escape"){td.textContent=cur;}});
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,641 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Garden Calendar - The Dark Elite</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
:root { --bg:#060708; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; --indoor:#a78bfa; --direct:#22c55e; --transplant:#60a5fa; --harvest:#f59e0b; --maintain:#9ca3af; --plant:#ec4899; }
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;min-height:100vh}
|
||||
.container{max-width:1300px;margin:0 auto;padding:2rem}
|
||||
header{display:flex;justify-content:space-between;align-items:center;padding-bottom:1.5rem;border-bottom:1px solid var(--border);margin-bottom:1rem;flex-wrap:wrap;gap:1rem}
|
||||
header h1{margin:0;font-size:1.5rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent);text-shadow:0 0 12px rgba(34,211,238,0.4)}
|
||||
header .sub{font-size:0.75rem;color:var(--muted);font-family:ui-monospace,monospace;margin-top:0.25rem}
|
||||
header a{color:var(--muted);text-decoration:none;font-size:0.85rem}
|
||||
header a:hover{color:var(--accent)}
|
||||
.legend{display:flex;gap:0.7rem;flex-wrap:wrap;padding:0.85rem 1rem;background:linear-gradient(135deg,rgba(18,20,24,0.7),rgba(10,11,13,0.85));border:1px solid var(--border);border-radius:6px;margin-bottom:1rem;font-size:0.75rem;font-family:ui-monospace,monospace}
|
||||
.legend span{display:flex;align-items:center;gap:0.3rem;text-transform:uppercase;letter-spacing:0.1em;color:var(--muted);cursor:pointer;padding:0.15rem 0.4rem;border-radius:3px;border:1px solid transparent}
|
||||
.legend span:hover{border-color:var(--border)}
|
||||
.legend span.off{opacity:0.35}
|
||||
.legend .dot{width:10px;height:10px;border-radius:50%}
|
||||
.month-nav{display:flex;gap:0.3rem;flex-wrap:wrap;margin-bottom:1.5rem}
|
||||
.month-nav a{flex:1;min-width:75px;text-align:center;background:rgba(18,20,24,0.9);border:1px solid var(--border);color:var(--text);padding:0.45rem;border-radius:4px;font-size:0.7rem;text-decoration:none;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace}
|
||||
.month-nav a:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.month-nav a.current{border-color:var(--accent);background:rgba(34,211,238,0.1);color:var(--accent)}
|
||||
.month{background:linear-gradient(135deg,rgba(18,20,24,0.4),rgba(10,11,13,0.6));border:1px solid var(--border);border-radius:8px;padding:1.5rem;margin-bottom:1.25rem;scroll-margin-top:1rem}
|
||||
.month.current{border-color:rgba(34,211,238,0.45);box-shadow:0 0 22px rgba(34,211,238,0.1)}
|
||||
.month h2{margin:0 0 0.25rem 0;font-size:1.5rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent);text-shadow:0 0 12px rgba(34,211,238,0.3)}
|
||||
.month .climate{font-size:0.7rem;color:var(--muted);font-family:ui-monospace,monospace;margin-bottom:1rem;letter-spacing:0.05em}
|
||||
.action-group{margin-top:1.25rem}
|
||||
.action-title{font-size:0.7rem;letter-spacing:0.25em;text-transform:uppercase;padding-left:0.65rem;border-left:3px solid;margin-bottom:0.65rem;font-weight:600}
|
||||
.action-title.indoor{color:var(--indoor);border-color:var(--indoor)}
|
||||
.action-title.direct{color:var(--direct);border-color:var(--direct)}
|
||||
.action-title.transplant{color:var(--transplant);border-color:var(--transplant)}
|
||||
.action-title.harvest{color:var(--harvest);border-color:var(--harvest)}
|
||||
.action-title.maintain{color:var(--maintain);border-color:var(--maintain)}
|
||||
.action-title.plant{color:var(--plant);border-color:var(--plant)}
|
||||
.plants{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:0.5rem}
|
||||
.plant{background:rgba(10,11,13,0.5);border:1px solid var(--border);border-left:3px solid;border-radius:4px;padding:0.55rem 0.8rem;font-size:0.85rem;transition:all 0.15s}
|
||||
.plant:hover{border-color:rgba(34,211,238,0.4);background:rgba(15,17,21,0.8)}
|
||||
.plant .name{color:var(--text);font-weight:500}
|
||||
.plant .note{color:var(--muted);font-size:0.72rem;margin-top:0.2rem;font-family:ui-monospace,monospace;letter-spacing:0.02em;line-height:1.4}
|
||||
.plant .cat-tag{display:inline-block;font-size:0.6rem;background:rgba(34,211,238,0.1);border:1px solid rgba(34,211,238,0.25);color:rgb(165,235,247);padding:0.05rem 0.35rem;border-radius:3px;margin-right:0.35rem;font-family:ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.1em;vertical-align:middle}
|
||||
.plant.indoor{border-left-color:var(--indoor)}
|
||||
.plant.direct{border-left-color:var(--direct)}
|
||||
.plant.transplant{border-left-color:var(--transplant)}
|
||||
.plant.harvest{border-left-color:var(--harvest)}
|
||||
.plant.maintain{border-left-color:var(--maintain)}
|
||||
.plant.plant{border-left-color:var(--plant)}
|
||||
.controls{display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:1rem}
|
||||
.controls select,.controls input{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.45rem 0.8rem;border-radius:4px;font-size:0.8rem;font-family:ui-monospace,monospace}
|
||||
.controls select:focus,.controls input:focus{outline:none;border-color:var(--accent)}
|
||||
.empty{color:var(--muted);font-style:italic;font-size:0.85rem;padding:0.5rem 0}
|
||||
@media (max-width:768px){.plants{grid-template-columns:1fr}.month-nav a{min-width:50px;font-size:0.65rem}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div>
|
||||
<h1>Garden Calendar</h1>
|
||||
<div class="sub">Milford, TX // Zone 8b // North Central Texas // Last frost Mar 15 // First frost Nov 15</div>
|
||||
</div>
|
||||
<a href="/" style="color:#9ca3af;text-decoration:none;font-size:0.85rem">← Hub</a>
|
||||
</header>
|
||||
|
||||
<div class="controls">
|
||||
<select id="catFilter">
|
||||
<option value="">All Categories</option>
|
||||
<option value="veg">Vegetables</option>
|
||||
<option value="herb">Herbs</option>
|
||||
<option value="fruit">Fruits & Berries</option>
|
||||
</select>
|
||||
<input type="text" id="search" placeholder="Filter plants...">
|
||||
</div>
|
||||
|
||||
<div class="legend" id="legend">
|
||||
indoor<span class="dot" style="background:var(--indoor)"></span>Start Indoors</span>
|
||||
direct<span class="dot" style="background:var(--direct)"></span>Direct Sow</span>
|
||||
transplant<span class="dot" style="background:var(--transplant)"></span>Transplant</span>
|
||||
harvest<span class="dot" style="background:var(--harvest)"></span>Harvest</span>
|
||||
plant<span class="dot" style="background:var(--plant)"></span>Plant</span>
|
||||
maintain<span class="dot" style="background:var(--maintain)"></span>Maintain</span>
|
||||
</div>
|
||||
|
||||
<div class="month-nav" id="nav"></div>
|
||||
<div id="months"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// veg / herb / fruit
|
||||
const CAL = {
|
||||
"January":{climate:"Avg 35-58°F. Dormant season. Last freeze ~Feb 5-Mar 1. Plan and order seeds.",actions:{
|
||||
indoor:[
|
||||
{n:"Onions (long-day & short-day)",c:"veg",note:"Start 10-12 weeks before last frost. Need 14+ hr daylight to bulb"},
|
||||
{n:"Leeks",c:"veg",note:"Start indoors now for spring transplant"},
|
||||
{n:"Celery",c:"veg",note:"Slow grower - start now, plant out March"},
|
||||
{n:"Perennial herbs (thyme, oregano, sage, chives)",c:"herb",note:"Start indoors under grow lights, 70°F soil"},
|
||||
{n:"Tomatoes (late month)",c:"veg",note:"6-8 weeks before transplant. Heat mat 75-80°F. 14 hr light"},
|
||||
{n:"Peppers (late month)",c:"veg",note:"8-10 weeks before transplant. Slowest germinator - bottom heat 80°F"},
|
||||
{n:"Eggplant (late month)",c:"veg",note:"8 weeks before transplant. Loves heat - 80°F bottom heat"}
|
||||
],
|
||||
direct:[
|
||||
{n:"English peas (late)",c:"veg",note:"Direct sow Jan 20-Mar 3. Soil temp 50°F. Inoculate seed. Trellis"},
|
||||
{n:"Spinach (late)",c:"veg",note:"Direct sow Jan 20-Mar 10. Cold tolerant. Bolts in heat - plant now"}
|
||||
],
|
||||
plant:[
|
||||
{n:"Asparagus crowns",c:"veg",note:"Plant Feb 1-Mar 1. 8-12\" deep trench. Long-term perennial - choose site carefully"},
|
||||
{n:"Bare root fruit trees (peach, plum, pear, apple)",c:"fruit",note:"Best planting window. Look for low-chill varieties (200-450 hr)"},
|
||||
{n:"Blackberries & raspberries (bare root)",c:"fruit",note:"Choose thornless varieties. Need trellis support"},
|
||||
{n:"Blueberries (Rabbiteye)",c:"fruit",note:"Acidic soil pH 4.5-5.5 - amend with sulfur. Plant 2+ varieties for pollination"},
|
||||
{n:"Grapes",c:"fruit",note:"Table or muscadine. Needs full sun, trellis, pruning"},
|
||||
{n:"Strawberries",c:"fruit",note:"Plant Jan-Feb. Crown at soil line. Mulch heavily"},
|
||||
{n:"Pecan trees",c:"fruit",note:"Pawnee, Caddo, Kanza work in N TX. Long taproot - dig deep"}
|
||||
],
|
||||
maintain:[
|
||||
{n:"Dormant prune peach/plum/apple/pear",c:"fruit",note:"Before bud break. Open center for peach/plum. Central leader for apple/pear"},
|
||||
{n:"Dormant prune grapes",c:"fruit",note:"Late Jan-early Feb. Spur or cane prune depending on variety"},
|
||||
{n:"Dormant oil spray on fruit trees",c:"fruit",note:"Smothers scale, mites, aphid eggs. Apply when temp >40°F for 24+ hr"},
|
||||
{n:"Plan garden layout",c:"veg",note:"Rotate crops! Don't plant nightshades where they were last year"}
|
||||
],
|
||||
harvest:[
|
||||
{n:"Winter greens (kale, collards, spinach)",c:"veg",note:"Pick outer leaves. Frost sweetens flavor"},
|
||||
{n:"Cabbage, Brussels sprouts",c:"veg",note:"Hardiest brassicas. Sprouts sweetest after frost"},
|
||||
{n:"Carrots, beets, turnips, radishes",c:"veg",note:"Mulch to prevent freeze damage if uncovered"},
|
||||
{n:"Persimmons (late season)",c:"fruit",note:"Asian non-astringent ripens late. American needs full ripening"},
|
||||
{n:"Pecans (continue cleanup)",c:"fruit",note:"Gather any remaining. Refrigerate or freeze"}
|
||||
]
|
||||
}},
|
||||
"February":{climate:"Avg 39-63°F. Late winter. Last freeze possible into early March. Soil warming.",actions:{
|
||||
indoor:[
|
||||
{n:"Tomatoes",c:"veg",note:"Prime starting time. 6-8 wk before last frost. Pot up at 4 true leaves"},
|
||||
{n:"Peppers (continue)",c:"veg",note:"Bottom heat critical. Slow germinator - be patient (14-21 days)"},
|
||||
{n:"Eggplant",c:"veg",note:"Start now for April transplant. Loves heat at every stage"},
|
||||
{n:"Broccoli, cabbage, cauliflower",c:"veg",note:"For fall crop? Skip - too late for spring brassicas from seed"},
|
||||
{n:"Basil (late month)",c:"herb",note:"Start indoors mid-Feb for April transplant. Won't tolerate frost"}
|
||||
],
|
||||
direct:[
|
||||
{n:"Onions (plants/sets)",c:"veg",note:"Jan 1-Feb 15. Plant 1\" deep, 4\" apart"},
|
||||
{n:"Onions (seed)",c:"veg",note:"Jan 1-Feb 15. Thin to 4\" later"},
|
||||
{n:"English peas",c:"veg",note:"Jan 20-Mar 3. Trellis. Bush varieties don't need support"},
|
||||
{n:"Spinach",c:"veg",note:"Jan 20-Mar 10. Direct sow only - hates transplant"},
|
||||
{n:"Irish potatoes",c:"veg",note:"Feb 1-Feb 15. Cut seed potatoes, cure 24 hr. Plant 4\" deep, hill as they grow"},
|
||||
{n:"Beets",c:"veg",note:"Feb 1-Feb 15. Soak seeds 24 hr first. Thin to 4\""},
|
||||
{n:"Carrots",c:"veg",note:"Feb 1-Feb 15. Loose soil 12\" deep. Keep moist until germination (2 wk)"},
|
||||
{n:"Swiss chard",c:"veg",note:"Feb 1-Mar 3. Heat tolerant - lasts into summer"},
|
||||
{n:"Cilantro",c:"veg",note:"Feb 1-Apr 1. Bolts in heat. Succession plant every 2-3 wk"},
|
||||
{n:"Collards, kale",c:"veg",note:"Feb 1-Mar 3. Cold hardy. Cut-and-come-again"},
|
||||
{n:"Lettuce (leaf, romaine, butter)",c:"veg",note:"Feb 1-Mar 31. Succession plant every 2 wk. Shade in late spring"},
|
||||
{n:"Kohlrabi",c:"veg",note:"Feb 1-Mar 10. Harvest at golf-ball size"},
|
||||
{n:"Mustard greens, turnips",c:"veg",note:"Feb 1-Mar 10. Fast crops - 30-40 days to harvest"},
|
||||
{n:"Parsley",c:"herb",note:"Feb 1-Mar 15. Direct sow only. Soak seeds 24 hr"},
|
||||
{n:"Radishes",c:"veg",note:"Feb 10-Apr 15. 25-day crop. Succession plant"},
|
||||
{n:"Dill",c:"herb",note:"Direct sow. Doesn't transplant. Self-seeds readily"}
|
||||
],
|
||||
transplant:[
|
||||
{n:"Broccoli, cabbage, cauliflower (from nursery)",c:"veg",note:"Feb 1-15. Hardened-off transplants only. Floating row cover for frost"},
|
||||
{n:"Brussels sprouts (from nursery)",c:"veg",note:"Feb 1-15. Need 90+ days to harvest"},
|
||||
{n:"Onion transplants",c:"veg",note:"Plant pencil-thick transplants. Don't water leaves"}
|
||||
],
|
||||
plant:[
|
||||
{n:"Asparagus crowns (final window)",c:"veg",note:"Feb 1-Mar 1. Hold off harvest 1-2 yr to build crown"},
|
||||
{n:"Fruit trees (last good window)",c:"fruit",note:"Plant bare root before bud break"},
|
||||
{n:"Figs",c:"fruit",note:"Brown Turkey, Celeste, LSU Purple. Very forgiving"},
|
||||
{n:"Pomegranates",c:"fruit",note:"Wonderful, Salavatski. Heat & drought tolerant"}
|
||||
],
|
||||
maintain:[
|
||||
{n:"Last dormant pruning window",c:"fruit",note:"Before bud break. Peaches especially"},
|
||||
{n:"Apply pre-emergent in garden borders",c:"maintain",note:"For weeds. NOT in vegetable beds"},
|
||||
{n:"Side-dress overwintered onions/garlic",c:"veg",note:"Light nitrogen feed (blood meal, fish emulsion)"}
|
||||
],
|
||||
harvest:[
|
||||
{n:"Last cool greens",c:"veg",note:"Before bolt. Spinach goes first when temps hit 70°F"},
|
||||
{n:"Carrots, beets, turnips, radishes",c:"veg",note:"Pull before they get woody"}
|
||||
]
|
||||
}},
|
||||
"March":{climate:"Avg 47-71°F. Last frost ~Mar 15. Spring planting in full swing. Soil warming fast.",actions:{
|
||||
indoor:[
|
||||
{n:"Basil",c:"herb",note:"Last call indoors. Transplant after soil hits 60°F"},
|
||||
{n:"Tomatoes (succession)",c:"veg",note:"For later harvest. Pot up to 1-gal containers"}
|
||||
],
|
||||
direct:[
|
||||
{n:"Beans, snap bush",c:"veg",note:"Mar 18-Apr 15. Soil 60°F+. Inoculant boosts yield"},
|
||||
{n:"Beans, snap pole",c:"veg",note:"Mar 18-Apr 15. Trellis 6-8' tall. More productive than bush"},
|
||||
{n:"Beans, lima bush & pole",c:"veg",note:"Mar 18-Apr 15. Soil must be 70°F+ - wait if cool"},
|
||||
{n:"Sweet corn",c:"veg",note:"Mar 18-Apr 30. Plant in blocks 4+ rows for pollination. Hill at 12\""},
|
||||
{n:"Cucumber",c:"veg",note:"Mar 18-Apr 30. Soil 60°F. Trellis vining types - cleaner fruit"},
|
||||
{n:"Summer squash, zucchini",c:"veg",note:"Mar 25-Apr 15. Plant hills. Watch for squash bugs starting late spring"},
|
||||
{n:"Winter squash",c:"veg",note:"Mar 25-Apr 15. Needs 85-100 days. Vining - give space"},
|
||||
{n:"Watermelon, cantaloupe",c:"veg",note:"Mar 25-Apr 30. Soil 70°F+. Vining - 8-12' between hills"},
|
||||
{n:"Pumpkin",c:"veg",note:"Mar 25-Apr 25. For Halloween harvest"},
|
||||
{n:"Radish, lettuce (last spring sow)",c:"veg",note:"Heat-tolerant varieties only at this point"}
|
||||
],
|
||||
transplant:[
|
||||
{n:"Tomatoes (after Mar 15)",c:"veg",note:"Mar 20-Apr 30. Bury deep - root from buried stem. Cage at planting"},
|
||||
{n:"Peppers (late month)",c:"veg",note:"Wait until nights stay above 55°F. Mulch black plastic for heat"},
|
||||
{n:"Eggplant (late month)",c:"veg",note:"Apr 1-30. Mulch with black plastic"}
|
||||
],
|
||||
plant:[
|
||||
{n:"Sweet potato slips (late month)",c:"veg",note:"Apr 15-Jun 1. Bury slips horizontally, leaves above soil"}
|
||||
],
|
||||
maintain:[
|
||||
{n:"Mulch heavily (3-4 inches)",c:"maintain",note:"Wood chips, straw, leaves. Conserves water, suppresses weeds, regulates temp"},
|
||||
{n:"Drip irrigation install",c:"maintain",note:"Saves 50%+ water vs sprinklers. Critical for hot summer"},
|
||||
{n:"Side-dress brassicas with nitrogen",c:"veg",note:"Heavy feeders - blood meal or fish emulsion"},
|
||||
{n:"Fruit tree spring spray",c:"fruit",note:"Copper fungicide at pink bud. Avoid spraying open flowers"}
|
||||
],
|
||||
harvest:[
|
||||
{n:"Spring lettuce, spinach, mustard",c:"veg",note:"Pick early morning for crispness"},
|
||||
{n:"Radishes (continuous)",c:"veg",note:"25-30 day crop. Harvest before they split"},
|
||||
{n:"English peas (late month)",c:"veg",note:"Pick when pods full but tender. Daily picking keeps producing"},
|
||||
{n:"Asparagus (year 3+)",c:"veg",note:"Snap at base when 8\" tall. 6-8 wk season then stop"},
|
||||
{n:"Strawberries (late month)",c:"fruit",note:"Pick fully red. Don't pull - snap stem above berry"}
|
||||
]
|
||||
}},
|
||||
"April":{climate:"Avg 56-78°F. Warm season planting peak. Last realistic frost passed. Soil 65-75°F.",actions:{
|
||||
direct:[
|
||||
{n:"Okra",c:"veg",note:"Apr 1-30. Soak seeds 24 hr. Soil must be 75°F+. Heat loving"},
|
||||
{n:"Southern peas (black-eyed, crowder, purple hull)",c:"veg",note:"Mar 30-Apr 30. Heat & drought tolerant. Nitrogen fixer"},
|
||||
{n:"Peppers",c:"veg",note:"Mar 30-May 30. Direct sow OR transplant"},
|
||||
{n:"Continue beans, corn, squash, cucumber, melons",c:"veg",note:"Last good window for spring planting"},
|
||||
{n:"Eggplant seed",c:"veg",note:"Apr 1-30. Or transplant - faster"}
|
||||
],
|
||||
transplant:[
|
||||
{n:"Sweet potato slips",c:"veg",note:"Apr 15-Jun 1. Plant horizontally, only leaves above soil. 100+ days to harvest"},
|
||||
{n:"Pepper transplants (peak window)",c:"veg",note:"Mar 30-May 30. Mulch heavily. Stake taller varieties"},
|
||||
{n:"Eggplant transplants",c:"veg",note:"Apr 1-30. Black plastic mulch boosts production"},
|
||||
{n:"Late tomato transplants",c:"veg",note:"For staggered harvest"},
|
||||
{n:"Basil",c:"herb",note:"Soil 60°F+ minimum. Pinch tops to bush out"}
|
||||
],
|
||||
plant:[
|
||||
{n:"Citrus (cold-hardy)",c:"fruit",note:"Satsuma, Meyer lemon, kumquat. Plant when freeze risk passed. South-facing wall best"}
|
||||
],
|
||||
maintain:[
|
||||
{n:"Begin pest scouting",c:"maintain",note:"Aphids, cabbage worms, flea beetles, cucumber beetles. Hand-pick first"},
|
||||
{n:"Side-dress tomatoes when first flowers appear",c:"veg",note:"Calcium important - prevents blossom end rot. Crushed eggshells or gypsum"},
|
||||
{n:"Thin seedlings ruthlessly",c:"veg",note:"Overcrowded plants compete = small yield"},
|
||||
{n:"Stake/cage tomatoes early",c:"veg",note:"Easier than after they sprawl"}
|
||||
],
|
||||
harvest:[
|
||||
{n:"Lettuce, spinach (final harvest)",c:"veg",note:"Heat is here - bolting starts. Pull before bitter"},
|
||||
{n:"Broccoli, cabbage, cauliflower",c:"veg",note:"Cut main head, side shoots continue 2-4 wk for broccoli"},
|
||||
{n:"Beets, carrots, turnips",c:"veg",note:"Last chance before heat makes them woody"},
|
||||
{n:"English peas (peak)",c:"veg",note:"Pick daily. Production drops when temps hit 80°F"},
|
||||
{n:"Strawberries (peak)",c:"fruit",note:"Daily picking through April-May"},
|
||||
{n:"Cilantro before bolt",c:"herb",note:"Collect seeds (coriander) when plant bolts"}
|
||||
]
|
||||
}},
|
||||
"May":{climate:"Avg 65-85°F. Heat ramping. Last warm-season planting window. Water demands rising.",actions:{
|
||||
direct:[
|
||||
{n:"Okra (continue)",c:"veg",note:"Plants love May heat. Cut pods at 3-4\" - bigger = woody"},
|
||||
{n:"Southern peas",c:"veg",note:"Continue planting. Heat tolerant"},
|
||||
{n:"Pumpkins for Halloween",c:"veg",note:"Apr 15-Jun 1. 100+ days. Plant by Jun 1 for late Oct harvest"},
|
||||
{n:"Cantaloupe (last call)",c:"veg",note:"Apr 15-Jun 1. After this it's too late before heat"}
|
||||
],
|
||||
transplant:[
|
||||
{n:"Sweet potato slips (peak window)",c:"veg",note:"Apr 15-Jun 1. Plant evening to reduce shock"},
|
||||
{n:"Late peppers, eggplant",c:"veg",note:"Last chance before extreme heat"}
|
||||
],
|
||||
maintain:[
|
||||
{n:"Deep water 1-2x weekly (1\" total)",c:"maintain",note:"Better than daily shallow. Encourages deep roots"},
|
||||
{n:"Top off mulch to 4\"",c:"maintain",note:"Critical for summer survival. Hot soil kills roots"},
|
||||
{n:"Side-dress tomatoes again",c:"veg",note:"Fertilize after first fruit sets, then monthly"},
|
||||
{n:"Sucker tomatoes (indeterminate types)",c:"veg",note:"Remove side shoots from leaf axils to focus energy"},
|
||||
{n:"Watch for squash bugs, spider mites, hornworms",c:"maintain",note:"Hand-pick early. Spinosad for caterpillars"}
|
||||
],
|
||||
harvest:[
|
||||
{n:"Garlic",c:"veg",note:"When lower leaves brown - usually mid-May. Cure 2 wk in shade"},
|
||||
{n:"Onions",c:"veg",note:"When tops fall over. Cure 2 wk in shade with tops attached"},
|
||||
{n:"Last spring greens",c:"veg",note:"Most cool crops finished"},
|
||||
{n:"Snap peas, English peas (last)",c:"veg",note:"Before heat ruins quality"},
|
||||
{n:"Early tomatoes (late month)",c:"veg",note:"First fruits ripening. Pick at first blush, ripen on counter"},
|
||||
{n:"Summer squash, zucchini",c:"veg",note:"Pick small (6-8\") for tender. Daily picking keeps producing"},
|
||||
{n:"Cucumber (early)",c:"veg",note:"Pick small. Skipping a day = baseball bat"},
|
||||
{n:"Beans (snap bush)",c:"veg",note:"Pick every 2-3 days for tender pods"},
|
||||
{n:"Strawberries (winding down)",c:"fruit",note:"Final harvest before plants rest in heat"},
|
||||
{n:"Blackberries (early varieties)",c:"fruit",note:"Pick when dull-looking, not shiny. Daily"}
|
||||
]
|
||||
}},
|
||||
"June":{climate:"Avg 73-92°F. Summer arrives. Tomato heat-stop approaching. Water critical.",actions:{
|
||||
indoor:[
|
||||
{n:"Fall tomato starts (late month)",c:"veg",note:"Start indoors for August transplant. Heat-set varieties: Heatmaster, Solar Fire, Phoenix"},
|
||||
{n:"Fall pepper starts",c:"veg",note:"Late June for August transplant"}
|
||||
],
|
||||
direct:[
|
||||
{n:"Okra (last main window)",c:"veg",note:"Heat-loving. Daily picking at 3-4\" pods"},
|
||||
{n:"Southern peas",c:"veg",note:"Drought tolerant - good summer crop"},
|
||||
{n:"Watermelon (last window)",c:"veg",note:"For early Sept harvest"}
|
||||
],
|
||||
maintain:[
|
||||
{n:"WATER DEEPLY",c:"maintain",note:"1-2\" weekly minimum. Mornings best - reduces fungal disease"},
|
||||
{n:"Shade cloth on lettuce/cool crops",c:"maintain",note:"30-50% shade extends spring crops a few weeks"},
|
||||
{n:"Watch for tomato blossom drop",c:"veg",note:"Stops setting fruit above 90°F. Normal - rest period coming"},
|
||||
{n:"Spider mite check on tomatoes",c:"maintain",note:"Tiny webs under leaves. Blast with water, neem oil"},
|
||||
{n:"Pull spent spring crops",c:"maintain",note:"Cover crops or solarize empty beds for summer"},
|
||||
{n:"Fertilize peppers, eggplant, okra",c:"veg",note:"Heavy feeders during fruiting"}
|
||||
],
|
||||
harvest:[
|
||||
{n:"Tomatoes (peak through early July)",c:"veg",note:"Pick at first blush. Heat stops fruit set above 92°F"},
|
||||
{n:"Peppers (continuous)",c:"veg",note:"Pick green or let ripen to red/yellow for more flavor"},
|
||||
{n:"Summer squash, zucchini",c:"veg",note:"Daily. Don't let monsters grow"},
|
||||
{n:"Cucumbers (peak)",c:"veg",note:"Daily. Slicers and picklers different harvest sizes"},
|
||||
{n:"Beans",c:"veg",note:"Continuous. Don't let pods go to seed - production stops"},
|
||||
{n:"Eggplant",c:"veg",note:"When skin shiny, slight finger pressure rebounds"},
|
||||
{n:"Okra (peak start)",c:"veg",note:"Every other day. Heavy production through August"},
|
||||
{n:"Blackberries",c:"fruit",note:"Peak. Dull black = ripe. Refrigerate immediately"},
|
||||
{n:"Peaches (early varieties)",c:"fruit",note:"June - early August depending on variety"},
|
||||
{n:"Plums",c:"fruit",note:"Methley, Morris ripen June. Pick when slight give"},
|
||||
{n:"Blueberries",c:"fruit",note:"Tifblue, Climax peak. Pick when fully blue with grayish bloom"},
|
||||
{n:"Mulberries",c:"fruit",note:"Pick from ground or shake into tarp"}
|
||||
]
|
||||
}},
|
||||
"July":{climate:"Avg 77-96°F. Brutal heat. Tomato fruit set stops above 92°F. Survival mode.",actions:{
|
||||
indoor:[
|
||||
{n:"Fall tomatoes (must start now)",c:"veg",note:"Started indoors early-mid July for late July transplant. Heat-set varieties"},
|
||||
{n:"Fall peppers, eggplant",c:"veg",note:"For August transplant. Same heat-set considerations"},
|
||||
{n:"Brassicas for fall (late month)",c:"veg",note:"Broccoli, cabbage, cauliflower, Brussels sprouts. Start indoors under shade"}
|
||||
],
|
||||
direct:[
|
||||
{n:"Okra (continue)",c:"veg",note:"Loves this. Peak production"},
|
||||
{n:"Southern peas",c:"veg",note:"Continue. Nitrogen fixers, will improve soil"},
|
||||
{n:"Pumpkins for Halloween (last call)",c:"veg",note:"Need 90-100 days - cutting it close"}
|
||||
],
|
||||
transplant:[
|
||||
{n:"Tomato transplants for fall (late month)",c:"veg",note:"Jun 15-Jul 30. Shade transplants first week. Heat-tolerant varieties"}
|
||||
],
|
||||
maintain:[
|
||||
{n:"WATER WATER WATER",c:"maintain",note:"Deep watering 2-3x/wk. Mulch maintenance"},
|
||||
{n:"Solarize empty beds",c:"maintain",note:"Clear plastic 4-6 wk kills weeds, nematodes, pathogens"},
|
||||
{n:"Cover crops in resting beds",c:"maintain",note:"Cowpeas, buckwheat. Will be tilled in for nitrogen/organic matter"},
|
||||
{n:"Plant nematode-resistant cover crops",c:"maintain",note:"Sunn hemp, marigolds break pest cycles"},
|
||||
{n:"Shade newly planted fall crops",c:"maintain",note:"30-50% shade for first 2-3 wk"},
|
||||
{n:"Continue tomato sucker removal",c:"veg",note:"Reduces foliar disease in heat"},
|
||||
{n:"Watch for whiteflies, thrips, hornworms",c:"maintain",note:"Peak pest season"}
|
||||
],
|
||||
harvest:[
|
||||
{n:"Tomatoes (until heat stops fruiting)",c:"veg",note:"Once heat-set ends, prune back hard - regrowth for fall"},
|
||||
{n:"Peppers (continuous)",c:"veg",note:"Hot peppers thrive. Bells slow but still produce"},
|
||||
{n:"Okra (PEAK)",c:"veg",note:"Daily picking. Two cuts/day in extreme heat"},
|
||||
{n:"Southern peas",c:"veg",note:"Shell or eat whole young"},
|
||||
{n:"Eggplant",c:"veg",note:"Peak production"},
|
||||
{n:"Melons, watermelon",c:"veg",note:"Thump for hollow sound. Tendril nearest fruit browns when ripe"},
|
||||
{n:"Peaches",c:"fruit",note:"Most varieties peak. Pick when slight give, fragrant"},
|
||||
{n:"Plums (continue)",c:"fruit",note:"Various varieties ripen through July"},
|
||||
{n:"Blackberries (winding down)",c:"fruit",note:"Final harvest most varieties"},
|
||||
{n:"Figs (Brown Turkey early)",c:"fruit",note:"When fully drooping. Pick early morning"}
|
||||
]
|
||||
}},
|
||||
"August":{climate:"Avg 76-96°F. Heat continues. Fall planting begins. Critical month for fall garden.",actions:{
|
||||
indoor:[
|
||||
{n:"Broccoli, cabbage, cauliflower, Brussels sprouts",c:"veg",note:"Start indoors under shade for Sept transplant. Or buy starts"},
|
||||
{n:"Fall lettuce (late month)",c:"veg",note:"Start indoors with cool ground"}
|
||||
],
|
||||
direct:[
|
||||
{n:"Beans, snap bush",c:"veg",note:"Aug 1-Sep 15. Fall crop - 60 days to harvest"},
|
||||
{n:"Beans, snap pole, lima",c:"veg",note:"Aug 1-Sep 15. Quick varieties only"},
|
||||
{n:"Beets, carrots",c:"veg",note:"Aug 15-Sep 30. Soil cooling - germination better"},
|
||||
{n:"Broccoli, cabbage, cauliflower SEED",c:"veg",note:"Aug 15-Sep 30. Direct sow or start indoors"},
|
||||
{n:"Brussels sprouts seed",c:"veg",note:"Aug 15-Sep 30. Long season - start early"},
|
||||
{n:"Swiss chard",c:"veg",note:"Aug 15-Sep 15. Cuts through winter"},
|
||||
{n:"Collards, kale",c:"veg",note:"Aug 25-Sep 20. Frost makes them sweeter"},
|
||||
{n:"Cucumber (fall crop)",c:"veg",note:"Aug 25-Sep 10. Pickling varieties faster"},
|
||||
{n:"Summer squash, zucchini",c:"veg",note:"Aug 1-Aug 30. Last warm-season planting"},
|
||||
{n:"Winter squash",c:"veg",note:"Aug 10-Aug 30. Cutting it close - choose 80-day varieties"},
|
||||
{n:"Cilantro",c:"herb",note:"Aug-Sep. Cool weather return - thrives until winter"},
|
||||
{n:"Dill",c:"herb",note:"Fall crop. Self-seeds for spring"}
|
||||
],
|
||||
transplant:[
|
||||
{n:"Tomatoes for fall (PEAK window)",c:"veg",note:"Aug 1-15. Shade first week. Heat-set varieties critical"},
|
||||
{n:"Peppers (fall)",c:"veg",note:"Aug 1-Oct 1. Will produce well into fall"},
|
||||
{n:"Eggplant (fall)",c:"veg",note:"Aug 1-Oct 15"}
|
||||
],
|
||||
maintain:[
|
||||
{n:"Shade cloth on transplants",c:"maintain",note:"30-50% shade for first 2 wk on fall transplants"},
|
||||
{n:"Water new plantings daily",c:"maintain",note:"Until established (~2 wk), then deep watering 2-3x/wk"},
|
||||
{n:"Cut back tomato plants",c:"veg",note:"Hard prune existing tomato plants - regrowth = fall harvest"},
|
||||
{n:"Soil prep with compost",c:"maintain",note:"Replenish before fall planting"}
|
||||
],
|
||||
harvest:[
|
||||
{n:"Okra (peak continues)",c:"veg",note:"Daily"},
|
||||
{n:"Southern peas",c:"veg",note:"Continuous"},
|
||||
{n:"Peppers",c:"veg",note:"Pick or let ripen"},
|
||||
{n:"Eggplant",c:"veg",note:"Peak through Sept"},
|
||||
{n:"Melons (final)",c:"veg",note:"Last of summer melons"},
|
||||
{n:"Tomatoes (final summer harvest)",c:"veg",note:"Heat-stressed fruit"},
|
||||
{n:"Sweet potatoes (test dig)",c:"veg",note:"Can start digging late month for early eats"},
|
||||
{n:"Figs (peak)",c:"fruit",note:"Daily picking. Birds will compete"},
|
||||
{n:"Pears (Asian and European)",c:"fruit",note:"Asian: ripe on tree. European: pick green, ripen in fridge"}
|
||||
]
|
||||
}},
|
||||
"September":{climate:"Avg 70-89°F. Cooling begins. Major fall planting month. Soil still warm.",actions:{
|
||||
direct:[
|
||||
{n:"Beets",c:"veg",note:"Sep 1-Oct 1. Excellent fall crop. Both roots and greens"},
|
||||
{n:"Carrots",c:"veg",note:"Sep 1-Sep 30. Fall sweetness much better than spring"},
|
||||
{n:"Broccoli, cabbage, cauliflower (seed)",c:"veg",note:"Sep 1-30. Best fall crop - won't bolt"},
|
||||
{n:"Brussels sprouts (seed)",c:"veg",note:"Aug 15-Sep 30. Frost sweetens. Best fall crop"},
|
||||
{n:"Chinese cabbage, bok choy",c:"veg",note:"Aug 10-30. Quick crop. Cool weather thrives"},
|
||||
{n:"Kale, collards (peak)",c:"veg",note:"Best planting time"},
|
||||
{n:"Kohlrabi",c:"veg",note:"Aug 15-Sep 20. Quick fall crop"},
|
||||
{n:"Lettuce (all types)",c:"veg",note:"Sep 1-30. Cool weather = peak quality"},
|
||||
{n:"Mustard greens",c:"veg",note:"Aug 25-Sep 20. Cuts through frost"},
|
||||
{n:"Onions (seed for transplants)",c:"veg",note:"Sep 1-20. For winter transplant"},
|
||||
{n:"Parsley",c:"herb",note:"Aug 15-Oct 10. Cold-hardy through winter"},
|
||||
{n:"English peas",c:"veg",note:"Sep 15-Nov 1. Fall crop - quick before frost"},
|
||||
{n:"Radishes",c:"veg",note:"Sep 20-Nov 15. Quick 25-30 day crop"},
|
||||
{n:"Spinach",c:"veg",note:"Sep 15-Nov 1. Best fall planting time - germination good now"},
|
||||
{n:"Swiss chard",c:"veg",note:"Continues from August. Will overwinter"},
|
||||
{n:"Turnips (both roots and greens)",c:"veg",note:"Aug 25-Nov 1. Quick crops both"},
|
||||
{n:"Cilantro (heavy)",c:"herb",note:"Peak fall planting - will produce through spring"}
|
||||
],
|
||||
transplant:[
|
||||
{n:"Broccoli, cabbage, cauliflower starts",c:"veg",note:"Sep 1-Oct 15. Best transplant window"},
|
||||
{n:"Brussels sprouts starts",c:"veg",note:"Sep 1-30. Needs longest season"},
|
||||
{n:"Continue tomatoes for late fall",c:"veg",note:"Last realistic window"},
|
||||
{n:"Lettuce transplants",c:"veg",note:"Faster to harvest than seed"}
|
||||
],
|
||||
plant:[
|
||||
{n:"Garlic (late month)",c:"veg",note:"Sep 1-Nov 1. Plant cloves pointed end up, 2\" deep. Harvest May"},
|
||||
{n:"Strawberry crowns",c:"fruit",note:"Sep-Oct. Mulch heavily for winter"}
|
||||
],
|
||||
maintain:[
|
||||
{n:"Resume regular watering schedule",c:"maintain",note:"Cooler temps = less stress. Adjust based on rainfall"},
|
||||
{n:"Compost summer plant debris",c:"maintain",note:"Hot compost only - cool pile can spread disease"},
|
||||
{n:"Cover crops on resting beds",c:"maintain",note:"Crimson clover, vetch fix nitrogen overwinter"}
|
||||
],
|
||||
harvest:[
|
||||
{n:"Sweet potatoes (peak)",c:"veg",note:"Dig before first frost. Cure 1-2 wk at 80°F+ for storage"},
|
||||
{n:"Okra (winding down)",c:"veg",note:"Production drops as cool nights begin"},
|
||||
{n:"Southern peas (final)",c:"veg",note:"Shell remaining for storage"},
|
||||
{n:"Late summer tomatoes",c:"veg",note:"From August replants"},
|
||||
{n:"Peppers (continuous)",c:"veg",note:"Production increases as heat breaks"},
|
||||
{n:"Eggplant",c:"veg",note:"Continuing well"},
|
||||
{n:"Apples (early varieties)",c:"fruit",note:"Anna, Dorsett Golden. Low-chill varieties"},
|
||||
{n:"Pears continuing",c:"fruit",note:"Most varieties ripen Aug-Sept"},
|
||||
{n:"Pomegranates",c:"fruit",note:"Late month. Pick when 'metallic' sound when tapped"}
|
||||
]
|
||||
}},
|
||||
"October":{climate:"Avg 61-80°F. Beautiful weather. Fall garden peak. First frost still ~6 weeks away.",actions:{
|
||||
direct:[
|
||||
{n:"Garlic (peak planting time)",c:"veg",note:"Oct 1-Nov 1. Plant individual cloves. Mulch heavily. Harvest May-June"},
|
||||
{n:"Asian greens (bok choy, tatsoi, mizuna)",c:"veg",note:"Quick fall crops"},
|
||||
{n:"Spinach (continuous)",c:"veg",note:"Will overwinter in mild winters"},
|
||||
{n:"Lettuce (continuous)",c:"veg",note:"Plant under cover after mid-month for winter harvest"},
|
||||
{n:"Radishes",c:"veg",note:"Continuous. Daikon for late planting"},
|
||||
{n:"Turnips, mustard",c:"veg",note:"Both greens and roots"},
|
||||
{n:"English peas (last)",c:"veg",note:"Through Oct - risky if frost early"},
|
||||
{n:"Carrots, beets (last)",c:"veg",note:"Through early Oct for fall harvest"},
|
||||
{n:"Cilantro continues",c:"herb",note:"Will produce all winter in mild years"},
|
||||
{n:"Parsley",c:"herb",note:"Cold hardy. Overwinters easily"}
|
||||
],
|
||||
transplant:[
|
||||
{n:"Last brassicas (broccoli, cabbage, cauliflower)",c:"veg",note:"Early month only. Need 60+ days before hard freeze"},
|
||||
{n:"Onion transplants (start)",c:"veg",note:"Sets or transplants for spring harvest"}
|
||||
],
|
||||
plant:[
|
||||
{n:"Strawberries",c:"fruit",note:"Plant crowns. Mulch heavy. Bear next spring"},
|
||||
{n:"Spring-flowering bulbs",c:"fruit",note:"Daffodils, hyacinths if ornamental garden"}
|
||||
],
|
||||
maintain:[
|
||||
{n:"Plan freeze protection",c:"maintain",note:"Frost cloth, row covers ready. First frost typically Nov 15"},
|
||||
{n:"Sharpen tools, organize shed",c:"maintain",note:"Winter prep"},
|
||||
{n:"Take soil tests",c:"maintain",note:"Send to Texas A&M AgriLife. Amend over winter"},
|
||||
{n:"Compost fall leaves",c:"maintain",note:"Free organic matter. Shred for faster breakdown"}
|
||||
],
|
||||
harvest:[
|
||||
{n:"Sweet potatoes (final)",c:"veg",note:"Before first frost. Cure for storage"},
|
||||
{n:"Tomatoes (final summer)",c:"veg",note:"Pick green ones before frost - ripen in paper bag with banana"},
|
||||
{n:"Peppers (peak with cool weather)",c:"veg",note:"Pick before first frost"},
|
||||
{n:"Winter squash, pumpkin",c:"veg",note:"When stems dry, rind hard. Cure 1-2 wk before storage"},
|
||||
{n:"Late okra (final)",c:"veg",note:"Until frost kills plant"},
|
||||
{n:"Cucumbers (fall crop)",c:"veg",note:"From Aug planting"},
|
||||
{n:"Beans (fall crop)",c:"veg",note:"From Aug planting"},
|
||||
{n:"Fall lettuce, spinach",c:"veg",note:"Peak quality with cool nights"},
|
||||
{n:"Persimmons",c:"fruit",note:"Asian varieties ripening. American after first frost"},
|
||||
{n:"Pecans (begin)",c:"fruit",note:"Harvest as they fall. Or shake tree gently"},
|
||||
{n:"Pomegranates",c:"fruit",note:"Continue. Storage life 2+ months refrigerated"}
|
||||
]
|
||||
}},
|
||||
"November":{climate:"Avg 50-70°F. First frost arrives ~Nov 15. Winter prep mode. Cool crops thrive.",actions:{
|
||||
direct:[
|
||||
{n:"Spinach (early)",c:"veg",note:"For winter & spring harvest. Cold hardy"},
|
||||
{n:"Radishes (continuous)",c:"veg",note:"Quick 25-day crop"},
|
||||
{n:"Lettuce (under cover)",c:"veg",note:"Cold frames or low tunnels extend through winter"},
|
||||
{n:"Kale (cold-hardy varieties)",c:"veg",note:"Vates, Red Russian survive 10°F"},
|
||||
{n:"Mâche/corn salad",c:"veg",note:"Extremely cold hardy. Slow growing but reliable winter green"},
|
||||
{n:"Cilantro (continues)",c:"herb",note:"Thrives in cool weather"}
|
||||
],
|
||||
plant:[
|
||||
{n:"Garlic (LAST call)",c:"veg",note:"Oct-Nov. Mulch heavy after planting"},
|
||||
{n:"Onion transplants (continue)",c:"veg",note:"Throughout month. Short-day for South - Texas Supersweet, Yellow Granex"},
|
||||
{n:"Asparagus crowns (late month)",c:"veg",note:"Begin window for dormant planting"},
|
||||
{n:"Bare-root fruit trees (begin)",c:"fruit",note:"Best planting season starts. Peaches, plums, apples"},
|
||||
{n:"Blackberries, raspberries",c:"fruit",note:"Begin bare-root planting season"}
|
||||
],
|
||||
maintain:[
|
||||
{n:"FROST PROTECTION READY",c:"maintain",note:"First frost typically Nov 15. Cover tender plants overnight"},
|
||||
{n:"Heavy mulch on perennials",c:"maintain",note:"4-6\" around strawberries, asparagus, herbs"},
|
||||
{n:"Empty hoses, drain irrigation",c:"maintain",note:"Before hard freeze"},
|
||||
{n:"Pull spent summer crops",c:"maintain",note:"Compost healthy debris, trash diseased"},
|
||||
{n:"Plant cover crops on empty beds",c:"maintain",note:"Crimson clover, winter rye, vetch"}
|
||||
],
|
||||
harvest:[
|
||||
{n:"Brassicas (broccoli, cabbage, cauliflower)",c:"veg",note:"Peak harvest from Sept plantings"},
|
||||
{n:"Kale, collards (peak)",c:"veg",note:"Sweetest after first frosts"},
|
||||
{n:"Brussels sprouts",c:"veg",note:"Pick from bottom up as they mature"},
|
||||
{n:"Carrots, beets, turnips",c:"veg",note:"From Sept plantings. Sweeter with cold"},
|
||||
{n:"Lettuce, spinach, chard",c:"veg",note:"Continuous from fall plantings"},
|
||||
{n:"Late peppers (before frost)",c:"veg",note:"Pick everything before hard freeze"},
|
||||
{n:"Persimmons (American)",c:"fruit",note:"After first frost. Soft when ripe"},
|
||||
{n:"Pecans (peak)",c:"fruit",note:"Daily collection. Beat trees if needed"},
|
||||
{n:"Pomegranates (last)",c:"fruit",note:"Final harvest"},
|
||||
{n:"Citrus (Satsuma, kumquat begin)",c:"fruit",note:"Pick before hard freeze, cover trees"}
|
||||
]
|
||||
}},
|
||||
"December":{climate:"Avg 41-60°F. Winter. Hard freezes possible. Dormant season for most. Planning time.",actions:{
|
||||
indoor:[
|
||||
{n:"Onions (start mid-late month)",c:"veg",note:"For January transplant. 10-12 weeks lead time"},
|
||||
{n:"Perennial herbs",c:"herb",note:"Sage, thyme, oregano, rosemary cuttings"}
|
||||
],
|
||||
plant:[
|
||||
{n:"Bare root fruit trees (peak season)",c:"fruit",note:"Dec-Feb best window. Apple, peach, plum, pear, fig"},
|
||||
{n:"Berries (blackberry, raspberry, blueberry)",c:"fruit",note:"Dormant planting. Choose proven Texas varieties"},
|
||||
{n:"Grapes",c:"fruit",note:"Bare root dormant planting"},
|
||||
{n:"Pecan trees",c:"fruit",note:"Dec-Feb. Dig wide hole - tap root is long"},
|
||||
{n:"Garlic (final call)",c:"veg",note:"If you missed Oct-Nov, plant by early Dec"},
|
||||
{n:"Strawberries (continuing)",c:"fruit",note:"Mulch heavily"}
|
||||
],
|
||||
maintain:[
|
||||
{n:"Dormant pruning fruit trees (late month)",c:"fruit",note:"After leaves fall. Heavy pruning OK"},
|
||||
{n:"Mulch tender perennials",c:"maintain",note:"Heavy mulch on figs, citrus, pomegranates"},
|
||||
{n:"Cover citrus during freezes",c:"fruit",note:"Christmas lights + tarp = several degrees protection"},
|
||||
{n:"Plan next year's garden",c:"maintain",note:"Order seeds. Sketch beds. Crop rotation planning"},
|
||||
{n:"Soil amendments",c:"maintain",note:"Compost, manure aged 6+ months, gypsum into beds"},
|
||||
{n:"Sharpen and oil tools",c:"maintain",note:"Winter maintenance for spring readiness"},
|
||||
{n:"Order seeds early",c:"maintain",note:"Heirloom suppliers sell out by January"}
|
||||
],
|
||||
harvest:[
|
||||
{n:"Citrus (Satsuma, Meyer lemon, kumquat)",c:"fruit",note:"Peak harvest. Refrigerate or process"},
|
||||
{n:"Kale, collards (peak)",c:"veg",note:"Sweetest in winter"},
|
||||
{n:"Brussels sprouts (peak)",c:"veg",note:"From bottom up"},
|
||||
{n:"Cabbage (slow growth, store on plant)",c:"veg",note:"Cold doesn't hurt - leave until needed"},
|
||||
{n:"Carrots, beets, turnips (store on ground)",c:"veg",note:"Mulch and pull as needed all winter"},
|
||||
{n:"Lettuce, spinach (cold frame)",c:"veg",note:"With protection"},
|
||||
{n:"Radishes, mustard, mâche",c:"veg",note:"Continuous from fall plantings"},
|
||||
{n:"Pecans (final cleanup)",c:"fruit",note:"Refrigerate or freeze for storage"}
|
||||
]
|
||||
}}
|
||||
};
|
||||
|
||||
const CAT_NAMES = {veg:"Vegetable",herb:"Herb",fruit:"Fruit"};
|
||||
const ACTION_NAMES = {indoor:"Start Indoors",direct:"Direct Sow",transplant:"Transplant Outdoors",harvest:"Harvest",plant:"Plant",maintain:"Maintenance"};
|
||||
const ACTION_ORDER = ["indoor","direct","transplant","plant","harvest","maintain"];
|
||||
|
||||
const offActions = new Set();
|
||||
let currentCat = "";
|
||||
let currentQuery = "";
|
||||
|
||||
function buildNav(){
|
||||
const months = Object.keys(CAL);
|
||||
const cur = new Date().getMonth();
|
||||
const nav = document.getElementById("nav");
|
||||
nav.innerHTML = months.map(function(m,i){
|
||||
var cls = (i===new Date().getMonth())?' current':'';
|
||||
return '<a href="#m'+i+'" class="'+cls+'" data-i="'+i+'">'+m.substr(0,3)+'</a>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function render(){
|
||||
const cont = document.getElementById("months");
|
||||
const cur = new Date().getMonth();
|
||||
const out = [];
|
||||
Object.entries(CAL).forEach(([month,data],i) => {
|
||||
let sections = [];
|
||||
ACTION_ORDER.forEach(action => {
|
||||
if (offActions.has(action)) return;
|
||||
const list = data.actions[action] || [];
|
||||
const filtered = list.filter(p => {
|
||||
if (currentCat && p.c !== currentCat) return false;
|
||||
if (currentQuery && !((p.n||"")+(p.note||"")).toLowerCase().includes(currentQuery)) return false;
|
||||
return true;
|
||||
});
|
||||
if (filtered.length === 0) return;
|
||||
sections.push(
|
||||
'<div class="action-group">' +
|
||||
'<div class="action-title '+action+'">'+ACTION_NAMES[action]+'</div>' +
|
||||
'<div class="plants">' +
|
||||
filtered.map(p =>
|
||||
'<div class="plant '+action+'">' +
|
||||
'<div class="name">'+(CAT_NAMES[p.c]?'<span class="cat-tag">'+CAT_NAMES[p.c]+'</span>':'')+escapeHtml(p.n)+'</div>' +
|
||||
(p.note ? '<div class="note">'+escapeHtml(p.note)+'</div>' : '') +
|
||||
'</div>'
|
||||
).join("") +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
});
|
||||
out.push(
|
||||
'<div class="month '+(i===cur?"current":"")+'" id="m'+i+'">' +
|
||||
'<h2>'+month+'</h2>' +
|
||||
'<div class="climate">'+data.climate+'</div>' +
|
||||
(sections.length ? sections.join("") : '<div class="empty">No items match current filters.</div>') +
|
||||
'</div>'
|
||||
);
|
||||
});
|
||||
cont.innerHTML = out.join("");
|
||||
}
|
||||
|
||||
function escapeHtml(s){
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
document.getElementById("legend").addEventListener("click", e => {
|
||||
const el = e.target.closest("span[data-action]");
|
||||
if (!el) return;
|
||||
const a = el.dataset.action;
|
||||
if (offActions.has(a)) { offActions.delete(a); el.classList.remove("off"); }
|
||||
else { offActions.add(a); el.classList.add("off"); }
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById("catFilter").addEventListener("change", e => {
|
||||
currentCat = e.target.value;
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById("search").addEventListener("input", e => {
|
||||
currentQuery = e.target.value.toLowerCase();
|
||||
render();
|
||||
});
|
||||
|
||||
buildNav();
|
||||
render();
|
||||
// Auto-scroll to current month
|
||||
setTimeout(() => {
|
||||
const cur = document.querySelector(".month.current");
|
||||
if (cur) cur.scrollIntoView({behavior:"smooth", block:"start"});
|
||||
}, 100);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
- Content:
|
||||
- Kiwix Library:
|
||||
- abbr: KL
|
||||
href: https://library.kiwix.org/
|
||||
target: _blank
|
||||
- Kiwix Downloads:
|
||||
- abbr: KD
|
||||
href: https://download.kiwix.org/zim/
|
||||
target: _blank
|
||||
- Archive.org Prepper:
|
||||
- abbr: AP
|
||||
href: https://archive.org/details/folkscanomy_prepper
|
||||
target: _blank
|
||||
- Archive.org Medical:
|
||||
- abbr: AM
|
||||
href: https://archive.org/details/folkscanomy_medical
|
||||
target: _blank
|
||||
- Archive.org Military:
|
||||
- abbr: AX
|
||||
href: https://archive.org/details/military-manuals
|
||||
target: _blank
|
||||
- Hesperian Books:
|
||||
- abbr: HB
|
||||
href: https://hesperian.org/books-and-resources/
|
||||
target: _blank
|
||||
|
||||
- Maps & Geo:
|
||||
- Protomaps Builds:
|
||||
- abbr: PM
|
||||
href: https://maps.protomaps.com/builds/
|
||||
target: _blank
|
||||
- Mapterhorn:
|
||||
- abbr: MT
|
||||
href: https://mapterhorn.com/
|
||||
target: _blank
|
||||
- USGS Topo:
|
||||
- abbr: US
|
||||
href: https://store.usgs.gov/map-locator
|
||||
target: _blank
|
||||
- OpenStreetMap:
|
||||
- abbr: OS
|
||||
href: https://www.openstreetmap.org/
|
||||
target: _blank
|
||||
|
||||
- Weather & Radio:
|
||||
- Open-Meteo API:
|
||||
- abbr: OM
|
||||
href: https://open-meteo.com/
|
||||
target: _blank
|
||||
- RainViewer:
|
||||
- abbr: RV
|
||||
href: https://www.rainviewer.com/
|
||||
target: _blank
|
||||
- NWS Alerts:
|
||||
- abbr: NW
|
||||
href: https://api.weather.gov/
|
||||
target: _blank
|
||||
- RepeaterBook:
|
||||
- abbr: RB
|
||||
href: https://www.repeaterbook.com/
|
||||
target: _blank
|
||||
- RadioReference:
|
||||
- abbr: RR
|
||||
href: https://www.radioreference.com/
|
||||
target: _blank
|
||||
|
||||
- Reference & Tools:
|
||||
- AgriLife Extension:
|
||||
- abbr: AG
|
||||
href: https://agrilifeextension.tamu.edu/
|
||||
target: _blank
|
||||
- USDA NCHFP:
|
||||
- abbr: UC
|
||||
href: https://nchfp.uga.edu/
|
||||
target: _blank
|
||||
- pmtiles CLI:
|
||||
- abbr: PT
|
||||
href: https://docs.protomaps.com/pmtiles/cli
|
||||
target: _blank
|
||||
- IA CLI Docs:
|
||||
- abbr: IA
|
||||
href: https://archive.org/developers/internetarchive/
|
||||
target: _blank
|
||||
- Govinfo:
|
||||
- abbr: GV
|
||||
href: https://www.govinfo.gov/
|
||||
target: _blank
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 382 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
@@ -0,0 +1,2 @@
|
||||
---
|
||||
# sample kubernetes config
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
# pve:
|
||||
# url: https://proxmox.host.or.ip:8006
|
||||
# token: username@pam!Token ID
|
||||
# secret: secret
|
||||
@@ -0,0 +1,43 @@
|
||||
- Tools:
|
||||
- Library:
|
||||
href: /library/
|
||||
description: Wikipedia, WikiMed, iFixit, Gutenberg
|
||||
icon: mdi-bookshelf-#22d3ee
|
||||
- Maps:
|
||||
href: /maps/
|
||||
description: North America - vector + topo
|
||||
icon: mdi-map-#22d3ee
|
||||
- PDF Library:
|
||||
href: /pdfs/
|
||||
description: Upload, browse, and view PDFs
|
||||
icon: mdi-file-pdf-box-#22d3ee
|
||||
- Frequencies:
|
||||
href: /freqs/
|
||||
description: Ham, GMRS, repeater, simplex
|
||||
icon: mdi-radio-tower-#22d3ee
|
||||
- Inventory:
|
||||
href: /inventory/
|
||||
description: Supplies, food, gear - track quantities
|
||||
icon: mdi-package-variant-#22d3ee
|
||||
- Overlay:
|
||||
href: /overlay/
|
||||
description: Plot routes, points, areas on the map
|
||||
icon: mdi-map-marker-path-#22d3ee
|
||||
- Garden:
|
||||
href: /garden/
|
||||
description: Zone 8b Texas - month by month planting guide
|
||||
icon: mdi-sprout-#22d3ee
|
||||
- Weather:
|
||||
href: /weather/
|
||||
description: Milford TX - current, radar, forecast
|
||||
icon: mdi-weather-partly-cloudy-#a78bfa
|
||||
|
||||
- System:
|
||||
- Portainer (Master):
|
||||
href: https://10.0.0.32:9443/
|
||||
description: Local Portainer - thedarkelite
|
||||
icon: portainer.png
|
||||
- This Server:
|
||||
href: "#"
|
||||
description: thedarkelite - 10.0.0.32
|
||||
icon: mdi-server-#10b981
|
||||
@@ -0,0 +1,15 @@
|
||||
title: The Dark Elite
|
||||
theme: dark
|
||||
color: slate
|
||||
target: _self
|
||||
hideVersion: true
|
||||
layout:
|
||||
Tools:
|
||||
style: row
|
||||
columns: 4
|
||||
icon: mdi-tools
|
||||
System:
|
||||
style: row
|
||||
columns: 2
|
||||
icon: mdi-server-network
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
- resources:
|
||||
cpu: true
|
||||
memory: true
|
||||
disk: /
|
||||
- search:
|
||||
provider: custom
|
||||
url: /search/?q=
|
||||
target: _self
|
||||
Binary file not shown.
@@ -0,0 +1,80 @@
|
||||
from fastapi import FastAPI, HTTPException, Form
|
||||
from fastapi.responses import HTMLResponse
|
||||
import os, json, uuid
|
||||
from datetime import datetime
|
||||
|
||||
DATA = "/data/inventory.json"
|
||||
app = FastAPI(redirect_slashes=False)
|
||||
|
||||
def load():
|
||||
if os.path.exists(DATA):
|
||||
with open(DATA) as f: return json.load(f)
|
||||
return {}
|
||||
|
||||
def save(d):
|
||||
with open(DATA, "w") as f: json.dump(d, f, indent=2)
|
||||
|
||||
@app.get("/api/list")
|
||||
def list_items():
|
||||
items = load()
|
||||
return sorted(
|
||||
[{"id": k, **v} for k, v in items.items()],
|
||||
key=lambda x: (x.get("category", ""), x.get("name", "").lower())
|
||||
)
|
||||
|
||||
@app.post("/api/add")
|
||||
def add(name: str = Form(""), category: str = Form(""), quantity: str = Form("1"),
|
||||
unit: str = Form(""), location: str = Form(""), expires: str = Form(""),
|
||||
notes: str = Form("")):
|
||||
items = load()
|
||||
fid = uuid.uuid4().hex[:12]
|
||||
try: qty = float(quantity or 1)
|
||||
except: qty = 1
|
||||
items[fid] = {
|
||||
"name": name, "category": category, "quantity": qty,
|
||||
"unit": unit, "location": location, "expires": expires,
|
||||
"notes": notes, "added": datetime.utcnow().isoformat()
|
||||
}
|
||||
save(items)
|
||||
return {"id": fid}
|
||||
|
||||
@app.patch("/api/update/{fid}")
|
||||
def update(fid: str, name: str = Form(None), category: str = Form(None),
|
||||
quantity: str = Form(None), unit: str = Form(None),
|
||||
location: str = Form(None), expires: str = Form(None),
|
||||
notes: str = Form(None)):
|
||||
items = load()
|
||||
if fid not in items: raise HTTPException(404)
|
||||
for k, v in {"name": name, "category": category, "unit": unit,
|
||||
"location": location, "expires": expires, "notes": notes}.items():
|
||||
if v is not None: items[fid][k] = v
|
||||
if quantity is not None:
|
||||
try: items[fid]["quantity"] = float(quantity)
|
||||
except: pass
|
||||
save(items)
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/api/adjust/{fid}")
|
||||
def adjust(fid: str, delta: str = Form(...)):
|
||||
items = load()
|
||||
if fid not in items: raise HTTPException(404)
|
||||
try:
|
||||
cur = float(items[fid].get("quantity", 0))
|
||||
items[fid]["quantity"] = max(0, cur + float(delta))
|
||||
except:
|
||||
raise HTTPException(400, "Invalid delta")
|
||||
save(items)
|
||||
return {"ok": True, "quantity": items[fid]["quantity"]}
|
||||
|
||||
@app.delete("/api/delete/{fid}")
|
||||
def delete(fid: str):
|
||||
items = load()
|
||||
if fid not in items: raise HTTPException(404)
|
||||
del items[fid]
|
||||
save(items)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def root():
|
||||
with open("/static/index.html") as f:
|
||||
return f.read()
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,226 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Inventory - The Dark Elite</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
:root { --bg:#060708; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; --danger:#dc2626; --warn:#f59e0b; --ok:#22c55e; }
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;min-height:100vh}
|
||||
.container{max-width:1300px;margin:0 auto;padding:2rem}
|
||||
header{display:flex;justify-content:space-between;align-items:center;padding-bottom:1.5rem;border-bottom:1px solid var(--border);margin-bottom:2rem}
|
||||
header h1{margin:0;font-size:1.5rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent);text-shadow:0 0 12px rgba(34,211,238,0.4)}
|
||||
header a{color:var(--muted);text-decoration:none;font-size:0.85rem}
|
||||
header a:hover{color:var(--accent)}
|
||||
.section-title{font-size:0.75rem;letter-spacing:0.25em;text-transform:uppercase;color:var(--accent);border-left:3px solid var(--accent);padding-left:0.75rem;margin:2rem 0 1rem 0;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem}
|
||||
.controls{display:flex;gap:0.5rem;flex-wrap:wrap}
|
||||
.controls input,.controls select{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.4rem 0.7rem;border-radius:4px;font-size:0.8rem;font-family:ui-monospace,monospace}
|
||||
.controls input:focus,.controls select:focus{outline:none;border-color:var(--accent)}
|
||||
.add-form{background:linear-gradient(135deg,rgba(18,20,24,0.7),rgba(10,11,13,0.85));border:1px solid var(--border);border-radius:8px;padding:1.25rem;display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:0.75rem;margin-bottom:2rem}
|
||||
.add-form input,.add-form select{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.55rem 0.7rem;border-radius:4px;font-size:0.85rem;font-family:ui-monospace,monospace}
|
||||
.add-form input:focus,.add-form select:focus{outline:none;border-color:var(--accent)}
|
||||
.add-form .full{grid-column:1/-1}
|
||||
.add-form button{background:rgba(34,211,238,0.15);border:1px solid var(--accent);color:var(--accent);padding:0.55rem;border-radius:4px;font-size:0.8rem;cursor:pointer;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace}
|
||||
.add-form button:hover{background:rgba(34,211,238,0.25)}
|
||||
table{width:100%;border-collapse:collapse;font-size:0.85rem;font-family:ui-monospace,monospace}
|
||||
th{text-align:left;color:var(--muted);font-weight:400;text-transform:uppercase;letter-spacing:0.1em;font-size:0.7rem;padding:0.7rem 0.5rem;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--bg);cursor:pointer;user-select:none}
|
||||
th:hover{color:var(--accent)}
|
||||
td{padding:0.5rem 0.5rem;border-bottom:1px solid rgba(80,90,100,0.1);vertical-align:middle}
|
||||
tr:hover td{background:rgba(34,211,238,0.04)}
|
||||
td.qty{color:var(--accent);font-weight:600;text-align:center;white-space:nowrap}
|
||||
td.editable{cursor:text}
|
||||
td.editable:hover{background:rgba(34,211,238,0.08)}
|
||||
td input{background:rgba(10,11,13,0.9);border:1px solid var(--accent);color:var(--accent);padding:0.2rem 0.4rem;border-radius:3px;font:inherit;width:100%;outline:none}
|
||||
.qty-btn{background:rgba(18,20,24,0.9);border:1px solid var(--border);color:var(--text);width:22px;height:22px;border-radius:3px;font-size:0.85rem;cursor:pointer;font-family:ui-monospace,monospace;line-height:1;padding:0}
|
||||
.qty-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.qty-btn.minus:hover{border-color:var(--warn);color:var(--warn)}
|
||||
.qty-val{display:inline-block;min-width:2.5rem;text-align:center;padding:0 0.4rem}
|
||||
.del{color:var(--muted);cursor:pointer;font-size:0.7rem;text-transform:uppercase;letter-spacing:0.1em}
|
||||
.del:hover{color:var(--danger)}
|
||||
.empty{color:var(--muted);text-align:center;padding:2rem;font-style:italic}
|
||||
.toast{position:fixed;bottom:2rem;right:2rem;background:rgba(18,20,24,0.95);border:1px solid var(--accent);color:var(--accent);padding:0.8rem 1.2rem;border-radius:4px;font-family:ui-monospace,monospace;font-size:0.85rem;z-index:100;display:none}
|
||||
.cat-pill{display:inline-block;background:rgba(34,211,238,0.12);border:1px solid rgba(34,211,238,0.3);color:rgb(165,235,247);font-size:0.65rem;padding:0.1rem 0.4rem;border-radius:3px;text-transform:uppercase;letter-spacing:0.1em}
|
||||
.expired{color:var(--danger)}
|
||||
.expiring{color:var(--warn)}
|
||||
.count{font-size:0.75rem;color:var(--muted);font-family:ui-monospace,monospace}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div><h1>Inventory</h1><div class="count" id="count">Loading...</div></div>
|
||||
<a href="/" style="color:#9ca3af;text-decoration:none;font-size:0.85rem">← Hub</a>
|
||||
</header>
|
||||
|
||||
<div class="section-title">Add Item</div>
|
||||
<form class="add-form" id="addForm">
|
||||
<input name="name" placeholder="Item name" required>
|
||||
<select name="category" id="catInput">
|
||||
<option value="">Category</option>
|
||||
<option>Food - Canned</option><option>Food - Dry Goods</option><option>Food - Frozen</option><option>Food - Fresh</option>
|
||||
<option>Water</option><option>Medical</option><option>Hygiene</option><option>Cleaning</option>
|
||||
<option>Tools</option><option>Hardware</option><option>Batteries</option><option>Fuel</option>
|
||||
<option>Ammunition</option><option>Clothing</option><option>Camping</option><option>Electronics</option>
|
||||
<option>Office</option><option>Pet/Livestock</option><option>Seeds</option><option>Other</option>
|
||||
</select>
|
||||
<input name="quantity" type="number" step="0.01" value="1" placeholder="Qty">
|
||||
<input name="unit" placeholder="Unit (cans, lbs, ea)">
|
||||
<input name="location" placeholder="Location (pantry, garage)">
|
||||
<input name="expires" type="date" placeholder="Expires">
|
||||
<input name="notes" class="full" placeholder="Notes">
|
||||
<button type="submit" class="full">+ Add Item</button>
|
||||
</form>
|
||||
|
||||
<div class="section-title">
|
||||
Inventory
|
||||
<div class="controls">
|
||||
<select id="filterCat"><option value="">All Categories</option></select>
|
||||
<select id="filterExp">
|
||||
<option value="">All Items</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="soon">Expiring < 30d</option>
|
||||
<option value="no-exp">No Expiration</option>
|
||||
</select>
|
||||
<input type="text" id="search" placeholder="Filter...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="name">Item</th>
|
||||
<th data-sort="category">Category</th>
|
||||
<th data-sort="quantity">Qty</th>
|
||||
<th data-sort="unit">Unit</th>
|
||||
<th data-sort="location">Location</th>
|
||||
<th data-sort="expires">Expires</th>
|
||||
<th data-sort="notes">Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rows"><tr><td colspan="8" class="empty">Loading...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script>
|
||||
let all=[]; let sortKey="category"; let sortDir=1;
|
||||
const FIELDS=["name","category","quantity","unit","location","expires","notes"];
|
||||
|
||||
function toast(m){const t=document.getElementById("toast");t.textContent=m;t.style.display="block";setTimeout(()=>t.style.display="none",1800);}
|
||||
function dayDiff(d){if(!d)return null;const ms=new Date(d).getTime()-Date.now();return Math.floor(ms/86400000);}
|
||||
function expClass(d){const dd=dayDiff(d);if(dd===null)return"";if(dd<0)return"expired";if(dd<30)return"expiring";return"";}
|
||||
|
||||
async function load(){
|
||||
const r=await fetch("/inventory/api/list");
|
||||
all=await r.json();
|
||||
populateCats();
|
||||
render();
|
||||
}
|
||||
|
||||
function populateCats(){
|
||||
const cats=[...new Set(all.map(x=>x.category).filter(Boolean))].sort();
|
||||
const sel=document.getElementById("filterCat");
|
||||
const cur=sel.value;
|
||||
sel.innerHTML='<option value="">All Categories</option>'+cats.map(c=>'<option>'+c+'</option>').join("");
|
||||
sel.value=cur;
|
||||
}
|
||||
|
||||
function render(){
|
||||
const q=document.getElementById("search").value.toLowerCase();
|
||||
const cat=document.getElementById("filterCat").value;
|
||||
const expF=document.getElementById("filterExp").value;
|
||||
let items=all.filter(x=>{
|
||||
if(cat && x.category!==cat)return false;
|
||||
if(expF==="expired"){const dd=dayDiff(x.expires);if(dd===null||dd>=0)return false;}
|
||||
if(expF==="soon"){const dd=dayDiff(x.expires);if(dd===null||dd<0||dd>=30)return false;}
|
||||
if(expF==="no-exp" && x.expires)return false;
|
||||
if(q){const s=FIELDS.map(f=>(x[f]||"")).join(" ").toLowerCase();if(!s.includes(q))return false;}
|
||||
return true;
|
||||
});
|
||||
items.sort((a,b)=>{
|
||||
let va=a[sortKey], vb=b[sortKey];
|
||||
if(sortKey==="quantity"){return ((parseFloat(va)||0)-(parseFloat(vb)||0))*sortDir;}
|
||||
return String(va||"").localeCompare(String(vb||""))*sortDir;
|
||||
});
|
||||
document.getElementById("count").textContent=items.length+" of "+all.length+" items";
|
||||
const tbody=document.getElementById("rows");
|
||||
if(!items.length){tbody.innerHTML='<tr><td colspan="8" class="empty">No items. Add one above.</td></tr>';return;}
|
||||
tbody.innerHTML=items.map(x=>{
|
||||
const ec=expClass(x.expires);
|
||||
return '<tr data-id="'+x.id+'">'+
|
||||
'<td class="editable" data-f="name">'+(x.name||"")+'</td>'+
|
||||
'<td class="editable" data-f="category">'+(x.category?'<span class="cat-pill">'+x.category+'</span>':"")+'</td>'+
|
||||
'<td class="qty"><button class="qty-btn minus" data-d="-1">−</button><span class="qty-val">'+(x.quantity||0)+'</span><button class="qty-btn plus" data-d="1">+</button></td>'+
|
||||
'<td class="editable" data-f="unit">'+(x.unit||"")+'</td>'+
|
||||
'<td class="editable" data-f="location">'+(x.location||"")+'</td>'+
|
||||
'<td class="editable '+ec+'" data-f="expires">'+(x.expires||"")+'</td>'+
|
||||
'<td class="editable" data-f="notes" style="max-width:280px">'+(x.notes||"")+'</td>'+
|
||||
'<td><span class="del">Del</span></td>'+
|
||||
'</tr>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
document.getElementById("addForm").addEventListener("submit",async e=>{
|
||||
e.preventDefault();
|
||||
const fd=new FormData(e.target);
|
||||
const r=await fetch("/inventory/api/add",{method:"POST",body:fd});
|
||||
if(r.ok){toast("Added");e.target.reset();e.target.quantity.value=1;load();}
|
||||
else toast("Failed");
|
||||
});
|
||||
|
||||
document.querySelectorAll("th[data-sort]").forEach(th=>{
|
||||
th.addEventListener("click",()=>{
|
||||
const k=th.dataset.sort;
|
||||
if(sortKey===k)sortDir*=-1; else {sortKey=k;sortDir=1;}
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("search").addEventListener("input",render);
|
||||
document.getElementById("filterCat").addEventListener("change",render);
|
||||
document.getElementById("filterExp").addEventListener("change",render);
|
||||
|
||||
document.getElementById("rows").addEventListener("click",async e=>{
|
||||
const tr=e.target.closest("tr"); if(!tr)return;
|
||||
const id=tr.dataset.id;
|
||||
if(e.target.classList.contains("del")){
|
||||
if(!confirm("Delete this item?"))return;
|
||||
await fetch("/inventory/api/delete/"+id,{method:"DELETE"});
|
||||
toast("Deleted");load();
|
||||
return;
|
||||
}
|
||||
if(e.target.classList.contains("qty-btn")){
|
||||
const delta=e.target.dataset.d;
|
||||
const fd=new FormData(); fd.append("delta",delta);
|
||||
const r=await fetch("/inventory/api/adjust/"+id,{method:"POST",body:fd});
|
||||
if(r.ok){
|
||||
const d=await r.json();
|
||||
tr.querySelector(".qty-val").textContent=d.quantity;
|
||||
const it=all.find(x=>x.id===id); if(it) it.quantity=d.quantity;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const td=e.target.closest("td.editable");
|
||||
if(!td || td.querySelector("input"))return;
|
||||
const field=td.dataset.f;
|
||||
const it=all.find(x=>x.id===id);
|
||||
const cur=field==="category"?(it.category||""):(it[field]||"");
|
||||
td.innerHTML='<input type="text" value="'+String(cur).replace(/"/g,""")+'">';
|
||||
const inp=td.querySelector("input"); inp.focus(); inp.select();
|
||||
const save=async()=>{
|
||||
const val=inp.value;
|
||||
const fd=new FormData(); fd.append(field,val);
|
||||
await fetch("/inventory/api/update/"+id,{method:"PATCH",body:fd});
|
||||
toast("Updated");load();
|
||||
};
|
||||
inp.addEventListener("blur",save);
|
||||
inp.addEventListener("keydown",ev=>{if(ev.key==="Enter")inp.blur();if(ev.key==="Escape"){td.textContent=cur;}});
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,242 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Library - The Dark Elite</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
:root { --bg:#060708; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; }
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;min-height:100vh}
|
||||
.container{max-width:1400px;margin:0 auto;padding:2rem}
|
||||
header{display:flex;justify-content:space-between;align-items:center;padding-bottom:1.5rem;border-bottom:1px solid var(--border);margin-bottom:2rem;flex-wrap:wrap;gap:1rem}
|
||||
header h1{margin:0;font-size:1.5rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent);text-shadow:0 0 12px rgba(34,211,238,0.4)}
|
||||
header a{color:var(--muted);text-decoration:none;font-size:0.85rem}
|
||||
header a:hover{color:var(--accent)}
|
||||
.controls{display:flex;gap:0.5rem;flex-wrap:wrap}
|
||||
.controls input,.controls select{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.45rem 0.7rem;border-radius:4px;font-size:0.85rem;font-family:ui-monospace,monospace}
|
||||
.controls input:focus,.controls select:focus{outline:none;border-color:var(--accent)}
|
||||
.section-title{font-size:0.75rem;letter-spacing:0.25em;text-transform:uppercase;color:var(--accent);border-left:3px solid var(--accent);padding-left:0.75rem;margin:2rem 0 1rem 0}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem}
|
||||
.card{background:linear-gradient(135deg,rgba(18,20,24,0.9),rgba(10,11,13,0.95));border:1px solid var(--border);border-radius:6px;padding:1rem;transition:all 0.15s;display:flex;gap:0.85rem;text-decoration:none;color:inherit;position:relative;overflow:hidden}
|
||||
.card::before{content:"";position:absolute;top:0;left:0;width:3px;height:100%;background:var(--accent);opacity:0;transition:opacity 0.2s}
|
||||
.card:hover{border-color:rgba(34,211,238,0.5);transform:translateY(-2px);box-shadow:0 0 25px rgba(34,211,238,0.18)}
|
||||
.card:hover::before{opacity:1}
|
||||
.card .icon{width:42px;height:42px;flex-shrink:0;border-radius:6px;background:rgba(34,211,238,0.08);display:flex;align-items:center;justify-content:center;border:1px solid rgba(34,211,238,0.2);overflow:hidden}
|
||||
.card .icon img{width:100%;height:100%;object-fit:contain}
|
||||
.card .info{min-width:0;flex:1}
|
||||
.card .title{font-size:0.95rem;color:var(--text);margin-bottom:0.2rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.card .desc{font-size:0.78rem;color:var(--muted);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;line-height:1.35}
|
||||
.card .meta{font-size:0.65rem;color:var(--muted);font-family:ui-monospace,monospace;letter-spacing:0.05em;margin-top:0.4rem;display:flex;gap:0.6rem;flex-wrap:wrap}
|
||||
.card .tag{display:inline-block;background:rgba(34,211,238,0.12);border:1px solid rgba(34,211,238,0.3);color:rgb(165,235,247);font-size:0.6rem;padding:0.1rem 0.4rem;border-radius:3px;font-family:ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.1em}
|
||||
.empty{color:var(--muted);text-align:center;padding:3rem;font-style:italic;grid-column:1/-1}
|
||||
.count{font-size:0.75rem;color:var(--muted);font-family:ui-monospace,monospace}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div><h1>Library</h1><div class="count" id="count">Loading...</div></div>
|
||||
<div class="controls">
|
||||
<select id="catFilter"><option value="">All Categories</option></select>
|
||||
<select id="sortBy">
|
||||
<option value="title">Sort: Title A-Z</option>
|
||||
<option value="size-desc">Sort: Size (Largest)</option>
|
||||
<option value="size-asc">Sort: Size (Smallest)</option>
|
||||
</select>
|
||||
<input type="text" id="search" placeholder="Filter...">
|
||||
<a href="/">← Hub</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="section-title" style="display:flex;justify-content:space-between;align-items:center;margin-top:0">
|
||||
<span>Admin</span>
|
||||
<button id="reloadBtn" style="background:rgba(18,20,24,0.9);border:1px solid rgba(80,90,100,0.3);color:#e5e7eb;padding:0.4rem 0.9rem;border-radius:4px;font-size:0.7rem;cursor:pointer;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace">Restart Kiwix</button>
|
||||
</div>
|
||||
<div id="dz" style="border:2px dashed rgba(34,211,238,0.4);border-radius:8px;padding:1.5rem;text-align:center;background:linear-gradient(135deg,rgba(18,20,24,0.7),rgba(10,11,13,0.85));margin-bottom:0.75rem;cursor:pointer;transition:all 0.15s">
|
||||
<div style="color:rgb(34,211,238);font-size:0.95rem;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace">Drop ZIM files here or click to upload</div>
|
||||
<div style="color:#9ca3af;font-size:0.8rem;margin-top:0.25rem">Multi-GB supported. After upload click Restart Kiwix.</div>
|
||||
<input type="file" id="fileInput" accept=".zim" multiple style="display:none">
|
||||
</div>
|
||||
<div id="upProgress" style="margin-bottom:1.5rem;font-family:ui-monospace,monospace;font-size:0.8rem"></div>
|
||||
<div class="section-title">Library</div>
|
||||
<div id="grid" class="grid"><div class="empty">Loading catalog...</div></div>
|
||||
</div>
|
||||
<script>
|
||||
let books = [];
|
||||
|
||||
const CATEGORIES = {
|
||||
'medical|wiki.*med|hesperian|dentist': 'Medical',
|
||||
'wikipedia|wikibooks|wikivoyage|wiktionary': 'Reference',
|
||||
'stackexchange|stack_exchange|stack.overflow': 'Q&A',
|
||||
'gutenberg|book': 'Books',
|
||||
'khan|crashcourse|appropedia|saylor|teded|ted|education': 'Education',
|
||||
'ifixit|repair': 'Repair',
|
||||
'gardening|farm|agriculture': 'Agriculture',
|
||||
'cooking|food|recipe': 'Food',
|
||||
'ham|amateur|radio|comms': 'Comms',
|
||||
'history|war': 'History',
|
||||
'survival|prepper|shtf': 'Survival',
|
||||
'outdoor|hunt|fish': 'Outdoors',
|
||||
'diy|home.improvement': 'DIY'
|
||||
};
|
||||
|
||||
function categoryFor(name) {
|
||||
const n = (name || '').toLowerCase();
|
||||
for (const [pattern, cat] of Object.entries(CATEGORIES)) {
|
||||
if (new RegExp(pattern).test(n)) return cat;
|
||||
}
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
function fmtSize(b) {
|
||||
if (!b) return '';
|
||||
if (b < 1048576) return (b/1024).toFixed(0)+' KB';
|
||||
if (b < 1073741824) return (b/1048576).toFixed(0)+' MB';
|
||||
return (b/1073741824).toFixed(1)+' GB';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch('/library/catalog/v2/entries?count=-1');
|
||||
const text = await r.text();
|
||||
const xml = new DOMParser().parseFromString(text, 'application/xml');
|
||||
const entries = Array.from(xml.querySelectorAll('entry'));
|
||||
books = entries.map(e => {
|
||||
const name = e.querySelector('name')?.textContent || '';
|
||||
const title = e.querySelector('title')?.textContent || name;
|
||||
const summary = e.querySelector('summary')?.textContent || '';
|
||||
const sizeEl = Array.from(e.querySelectorAll('link')).find(l => l.getAttribute('type') === 'application/x-zim');
|
||||
const size = sizeEl ? parseInt(sizeEl.getAttribute('length') || '0') : 0;
|
||||
const iconLink = Array.from(e.querySelectorAll('link')).find(l => l.getAttribute('rel') === 'http://opds-spec.org/image/thumbnail');
|
||||
const icon = iconLink ? iconLink.getAttribute('href') : '';
|
||||
const contentLink = Array.from(e.querySelectorAll('link')).find(l => (l.getAttribute('href')||'').startsWith('/library/content/'));
|
||||
const url = contentLink ? contentLink.getAttribute('href') : ('/library/content/' + name);
|
||||
const dlLink = Array.from(e.querySelectorAll('link')).find(l => (l.getAttribute('type')||'') === 'application/x-zim');
|
||||
const filename = dlLink ? (dlLink.getAttribute('href')||'').split('/').pop() : (name + '.zim');
|
||||
return { name, title, summary, size, icon, url, filename, category: categoryFor(name + ' ' + title) };
|
||||
});
|
||||
render();
|
||||
populateCats();
|
||||
} catch (e) {
|
||||
document.getElementById('grid').innerHTML = '<div class="empty">Failed to load catalog: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function populateCats() {
|
||||
const cats = [...new Set(books.map(b => b.category))].sort();
|
||||
const sel = document.getElementById('catFilter');
|
||||
cats.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c;
|
||||
opt.textContent = c;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
const q = document.getElementById('search').value.toLowerCase();
|
||||
const cat = document.getElementById('catFilter').value;
|
||||
const sort = document.getElementById('sortBy').value;
|
||||
|
||||
let filtered = books.filter(b => {
|
||||
if (cat && b.category !== cat) return false;
|
||||
if (q && !(b.title.toLowerCase().includes(q) || b.summary.toLowerCase().includes(q) || b.name.toLowerCase().includes(q))) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
if (sort === 'size-desc') return b.size - a.size;
|
||||
if (sort === 'size-asc') return a.size - b.size;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
document.getElementById('count').textContent = filtered.length + ' of ' + books.length + ' books';
|
||||
|
||||
const grid = document.getElementById('grid');
|
||||
if (!filtered.length) {
|
||||
grid.innerHTML = '<div class="empty">No matches.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = filtered.map(function(b){
|
||||
var iconHtml = b.icon ? '<img src="'+b.icon+'" alt="">' : '\ud83d\udcd6';
|
||||
var sizeHtml = b.size ? '<span>'+fmtSize(b.size)+'</span>' : '';
|
||||
return '<a class="card" href="'+b.url+'" target="_blank">' +
|
||||
'<div class="icon">'+iconHtml+'</div>' +
|
||||
'<div class="info">' +
|
||||
'<div class="title">'+b.title+'</div>' +
|
||||
'<div class="desc">'+(b.summary||' ')+'</div>' +
|
||||
'<div class="meta">' +
|
||||
'<span class="tag">'+b.category+'</span>' +
|
||||
sizeHtml +
|
||||
'<span class="del-zim" data-fn="'+b.filename+'" data-title="'+b.title.replace(/"/g,""")+'" style="margin-left:auto;color:#9ca3af;cursor:pointer;font-size:0.6rem;letter-spacing:0.1em;text-transform:uppercase">Delete</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</a>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.getElementById('search').addEventListener('input', render);
|
||||
document.getElementById('catFilter').addEventListener('change', render);
|
||||
document.getElementById('sortBy').addEventListener('change', render);
|
||||
|
||||
|
||||
function showUpProgress(name){
|
||||
const wrap=document.createElement("div");
|
||||
wrap.style.cssText="margin:0.4rem 0;color:#22d3ee";
|
||||
wrap.innerHTML=name+' <span class="pct">0%</span><div style="height:4px;background:rgba(80,90,100,0.2);border-radius:2px;overflow:hidden;margin-top:0.2rem"><div class="bar" style="height:100%;background:#22d3ee;width:0%;transition:width 0.2s"></div></div>';
|
||||
document.getElementById("upProgress").appendChild(wrap);
|
||||
return wrap;
|
||||
}
|
||||
function uploadOne(file){
|
||||
return new Promise(resolve=>{
|
||||
const wrap=showUpProgress(file.name);
|
||||
const bar=wrap.querySelector(".bar");
|
||||
const pct=wrap.querySelector(".pct");
|
||||
const xhr=new XMLHttpRequest();
|
||||
xhr.open("POST","/library-admin/api/upload");
|
||||
xhr.upload.onprogress=e=>{if(e.lengthComputable){const p=Math.round(e.loaded/e.total*100);bar.style.width=p+"%";pct.textContent=p+"%";}};
|
||||
xhr.onload=()=>{if(xhr.status===200){pct.textContent="DONE";bar.style.background="#22c55e";}else{pct.textContent="FAIL";bar.style.background="#dc2626";}resolve();};
|
||||
const fd=new FormData(); fd.append("file",file); xhr.send(fd);
|
||||
});
|
||||
}
|
||||
async function uploadFiles(files){
|
||||
for(const f of files){if(!f.name.toLowerCase().endsWith(".zim"))continue; await uploadOne(f);}
|
||||
}
|
||||
document.getElementById("dz").addEventListener("click",e=>{if(e.target.tagName!=="INPUT")document.getElementById("fileInput").click();});
|
||||
document.getElementById("fileInput").addEventListener("change",e=>uploadFiles(e.target.files));
|
||||
document.getElementById("dz").addEventListener("dragover",e=>{e.preventDefault();e.currentTarget.style.borderColor="#22d3ee";});
|
||||
document.getElementById("dz").addEventListener("dragleave",e=>{e.currentTarget.style.borderColor="rgba(34,211,238,0.4)";});
|
||||
document.getElementById("dz").addEventListener("drop",e=>{e.preventDefault();e.currentTarget.style.borderColor="rgba(34,211,238,0.4)";uploadFiles(e.dataTransfer.files);});
|
||||
document.getElementById("reloadBtn").addEventListener("click",async ()=>{
|
||||
const btn=document.getElementById("reloadBtn"); btn.textContent="Restarting..."; btn.disabled=true;
|
||||
try{const r=await fetch("/library-admin/api/reload",{method:"POST"}); btn.textContent=r.ok?"Restarted - reloading catalog...":"Failed";
|
||||
if(r.ok){setTimeout(()=>{btn.textContent="Restart Kiwix";btn.disabled=false;load();},3000);}
|
||||
else{setTimeout(()=>{btn.textContent="Restart Kiwix";btn.disabled=false;},2000);}
|
||||
}catch(e){btn.textContent="Error";setTimeout(()=>{btn.textContent="Restart Kiwix";btn.disabled=false;},2000);}
|
||||
});
|
||||
|
||||
|
||||
document.getElementById("grid").addEventListener("click", async function(e){
|
||||
const t = e.target.closest(".del-zim");
|
||||
if(!t) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const fn = t.dataset.fn;
|
||||
if(!confirm("Delete "+t.dataset.title+"?\nFile: "+fn+"\n\nKiwix will need restart after.")) return;
|
||||
t.textContent = "Deleting...";
|
||||
try {
|
||||
const r = await fetch("/library-admin/api/delete/"+encodeURIComponent(fn), {method:"DELETE"});
|
||||
if(r.ok){ t.textContent = "Deleted - restart Kiwix"; t.style.color = "#22c55e"; }
|
||||
else { t.textContent = "Failed"; t.style.color = "#dc2626"; }
|
||||
} catch(err) { t.textContent = "Error"; t.style.color = "#dc2626"; }
|
||||
});
|
||||
|
||||
// Make the delete look like a hover state
|
||||
const css = document.createElement("style");
|
||||
css.textContent = ".del-zim:hover{color:#dc2626 !important}";
|
||||
document.head.appendChild(css);
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>The Dark Elite - Maps</title>
|
||||
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||
https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css
|
||||
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
||||
<script src="https://unpkg.com/pmtiles@3.2.1/dist/pmtiles.js"></script>
|
||||
<script src="https://unpkg.com/protomaps-themes-base@4.3.0/dist/protomaps-themes-base.js"></script>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; background: #060708; font-family: system-ui, sans-serif; }
|
||||
#map { position: absolute; top: 0; bottom: 0; width: 100%; }
|
||||
#header {
|
||||
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
|
||||
background: rgba(6,7,8,0.85); color: #e5e7eb;
|
||||
padding: 10px 16px; border-bottom: 1px solid rgba(34,211,238,0.25);
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
#header .left { display: flex; gap: 1rem; align-items: center; }
|
||||
#header a { color: #9ca3af; text-decoration: none; font-size: 0.85rem; }
|
||||
#header a:hover { color: #22d3ee; }
|
||||
#header strong { color: #22d3ee; letter-spacing: 0.2em; text-transform: uppercase; font-size: 0.9rem; text-shadow: 0 0 8px rgba(34,211,238,0.4); }
|
||||
#layers {
|
||||
display: flex; gap: 0.4rem;
|
||||
}
|
||||
#layers button {
|
||||
background: rgba(18,20,24,0.9); color: #e5e7eb;
|
||||
border: 1px solid rgba(80,90,100,0.3); border-radius: 4px;
|
||||
padding: 0.35rem 0.8rem; font-size: 0.75rem; cursor: pointer;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
#layers button.active {
|
||||
border-color: #22d3ee; color: #22d3ee;
|
||||
box-shadow: 0 0 12px rgba(34,211,238,0.25);
|
||||
}
|
||||
#layers button:hover:not(.active) { border-color: rgba(34,211,238,0.5); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="header">
|
||||
<div class="left"><strong>Maps</strong></div>
|
||||
<div style="display:flex;gap:0.75rem;align-items:center"><div id="layers">
|
||||
<button data-mode="base" class="active">Street</button>
|
||||
<button data-mode="terrain">Topo</button>
|
||||
</div><a href="/" style="color:#9ca3af;text-decoration:none;font-size:0.85rem">← Hub</a></div>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
<script>
|
||||
const protocol = new pmtiles.Protocol();
|
||||
maplibregl.addProtocol("pmtiles", protocol.tile);
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: 'map',
|
||||
style: {
|
||||
version: 8,
|
||||
glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf',
|
||||
sprite: 'https://protomaps.github.io/basemaps-assets/sprites/v4/dark',
|
||||
sources: {
|
||||
protomaps: { type: 'vector', url: 'pmtiles:///maps/base.pmtiles' },
|
||||
terrain: { type: 'raster-dem', url: 'pmtiles:///maps/terrain.pmtiles', tileSize: 512, encoding: 'terrarium', maxzoom: 12 }
|
||||
},
|
||||
layers: protomaps_themes_base.default('protomaps', 'dark')
|
||||
},
|
||||
center: [-97.0, 39.0],
|
||||
zoom: 4
|
||||
});
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl());
|
||||
map.addControl(new maplibregl.ScaleControl());
|
||||
|
||||
map.on('load', () => {
|
||||
// Add hillshade layer (initially hidden)
|
||||
map.addLayer({
|
||||
id: 'hillshade',
|
||||
type: 'hillshade',
|
||||
source: 'terrain',
|
||||
layout: { visibility: 'none' },
|
||||
paint: {
|
||||
'hillshade-exaggeration': 0.6,
|
||||
'hillshade-shadow-color': '#000000',
|
||||
'hillshade-highlight-color': '#ffffff',
|
||||
'hillshade-accent-color': '#22d3ee'
|
||||
}
|
||||
}, map.getStyle().layers[1].id);
|
||||
|
||||
setMode('base');
|
||||
});
|
||||
|
||||
function setMode(mode) {
|
||||
document.querySelectorAll('#layers button').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.mode === mode);
|
||||
});
|
||||
|
||||
const vectorLayers = map.getStyle().layers.filter(l => l.source === 'protomaps').map(l => l.id);
|
||||
|
||||
if (mode === 'base') {
|
||||
map.setLayoutProperty('hillshade', 'visibility', 'none');
|
||||
vectorLayers.forEach(id => map.setLayoutProperty(id, 'visibility', 'visible'));
|
||||
if (map.getTerrain()) map.setTerrain(null);
|
||||
} else if (mode === 'terrain') {
|
||||
map.setLayoutProperty('hillshade', 'visibility', 'visible');
|
||||
vectorLayers.forEach(id => {
|
||||
// Keep only labels and roads visible for context
|
||||
const layer = map.getStyle().layers.find(l => l.id === id);
|
||||
const keep = layer && (layer.type === 'symbol' || id.includes('roads') || id.includes('water'));
|
||||
map.setLayoutProperty(id, 'visibility', keep ? 'visible' : 'none');
|
||||
});
|
||||
if (map.getTerrain()) map.setTerrain(null);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('#layers button').forEach(btn => {
|
||||
btn.addEventListener('click', () => setMode(btn.dataset.mode));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -0,0 +1,58 @@
|
||||
from fastapi import FastAPI, HTTPException, Form, Body
|
||||
from fastapi.responses import HTMLResponse
|
||||
import os, json, uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
DATA = "/data/overlay.json"
|
||||
app = FastAPI(redirect_slashes=False)
|
||||
|
||||
def load():
|
||||
if os.path.exists(DATA):
|
||||
with open(DATA) as f: return json.load(f)
|
||||
return {}
|
||||
|
||||
def save(d):
|
||||
with open(DATA, "w") as f: json.dump(d, f, indent=2)
|
||||
|
||||
@app.get("/api/list")
|
||||
def list_features():
|
||||
items = load()
|
||||
return [{"id": k, **v} for k, v in items.items()]
|
||||
|
||||
@app.post("/api/add")
|
||||
def add(payload: dict = Body(...)):
|
||||
items = load()
|
||||
fid = uuid.uuid4().hex[:12]
|
||||
items[fid] = {
|
||||
"name": payload.get("name", ""),
|
||||
"category": payload.get("category", "general"),
|
||||
"color": payload.get("color", "#22d3ee"),
|
||||
"notes": payload.get("notes", ""),
|
||||
"geometry": payload.get("geometry", {}), # GeoJSON geometry
|
||||
"added": datetime.utcnow().isoformat(),
|
||||
}
|
||||
save(items)
|
||||
return {"id": fid, **items[fid]}
|
||||
|
||||
@app.patch("/api/update/{fid}")
|
||||
def update(fid: str, payload: dict = Body(...)):
|
||||
items = load()
|
||||
if fid not in items: raise HTTPException(404)
|
||||
for k in ["name", "category", "color", "notes", "geometry"]:
|
||||
if k in payload: items[fid][k] = payload[k]
|
||||
save(items)
|
||||
return {"ok": True}
|
||||
|
||||
@app.delete("/api/delete/{fid}")
|
||||
def delete(fid: str):
|
||||
items = load()
|
||||
if fid not in items: raise HTTPException(404)
|
||||
del items[fid]
|
||||
save(items)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def root():
|
||||
with open("/static/index.html") as f:
|
||||
return f.read()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,478 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Overlay - The Dark Elite</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
|
||||
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
||||
<script src="https://unpkg.com/pmtiles@3.2.1/dist/pmtiles.js"></script>
|
||||
<script src="https://unpkg.com/protomaps-themes-base@4.3.0/dist/protomaps-themes-base.js"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.css">
|
||||
<script src="https://unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.js"></script>
|
||||
<style>
|
||||
:root { --bg:#060708; --panel:#0f1115; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; --danger:#dc2626; }
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;height:100vh;overflow:hidden}
|
||||
#app{display:grid;grid-template-columns:340px 1fr;height:100vh}
|
||||
#sidebar{background:var(--panel);border-right:1px solid var(--border);overflow-y:auto;display:flex;flex-direction:column}
|
||||
#sidebar header{padding:1rem;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
|
||||
#sidebar header h1{margin:0;font-size:1rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent);text-shadow:0 0 12px rgba(34,211,238,0.4)}
|
||||
#sidebar header a{color:var(--muted);text-decoration:none;font-size:0.75rem}
|
||||
#sidebar header a:hover{color:var(--accent)}
|
||||
.tool-row{display:flex;gap:0.4rem;padding:0.75rem;border-bottom:1px solid var(--border);flex-wrap:wrap}
|
||||
.tool-btn{flex:1;background:rgba(18,20,24,0.9);border:1px solid var(--border);color:var(--text);padding:0.5rem;border-radius:4px;font-size:0.7rem;cursor:pointer;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace;min-width:60px}
|
||||
.tool-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.tool-btn.active{border-color:var(--accent);color:var(--accent);background:rgba(34,211,238,0.1)}
|
||||
.filter-row{padding:0.75rem;border-bottom:1px solid var(--border);display:flex;gap:0.4rem;flex-direction:column}
|
||||
.filter-row select,.filter-row input{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.4rem 0.6rem;border-radius:4px;font-size:0.8rem;font-family:ui-monospace,monospace}
|
||||
.filter-row select:focus,.filter-row input:focus{outline:none;border-color:var(--accent)}
|
||||
.list{flex:1;overflow-y:auto;padding:0.5rem}
|
||||
.item{background:linear-gradient(135deg,rgba(18,20,24,0.85),rgba(10,11,13,0.95));border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:4px;padding:0.6rem 0.75rem;margin-bottom:0.5rem;cursor:pointer;transition:all 0.15s}
|
||||
.item:hover{border-color:rgba(34,211,238,0.5);transform:translateX(2px)}
|
||||
.item .name{font-size:0.85rem;color:var(--text);font-weight:500}
|
||||
.item .meta{font-size:0.65rem;color:var(--muted);font-family:ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.1em;margin-top:0.2rem;display:flex;justify-content:space-between;align-items:center}
|
||||
.item .del{color:var(--muted);font-size:0.6rem;cursor:pointer}
|
||||
.item .del:hover{color:var(--danger)}
|
||||
.empty{color:var(--muted);text-align:center;padding:1.5rem;font-style:italic;font-size:0.8rem}
|
||||
#map{height:100vh}
|
||||
#dialog{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--panel);border:1px solid var(--accent);border-radius:8px;padding:1.25rem;width:340px;z-index:200;display:none;box-shadow:0 10px 40px rgba(0,0,0,0.7)}
|
||||
#dialog h3{margin:0 0 0.85rem 0;font-size:0.8rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent)}
|
||||
#dialog label{display:block;font-size:0.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.1em;margin:0.6rem 0 0.2rem 0}
|
||||
#dialog input,#dialog select,#dialog textarea{width:100%;background:rgba(10,11,13,0.7);border:1px solid var(--border);color:var(--text);padding:0.5rem 0.7rem;border-radius:4px;font-size:0.85rem;font-family:ui-monospace,monospace}
|
||||
#dialog textarea{resize:vertical;min-height:60px}
|
||||
#dialog .actions{display:flex;gap:0.5rem;margin-top:1rem}
|
||||
#dialog .actions button{flex:1;background:rgba(34,211,238,0.15);border:1px solid var(--accent);color:var(--accent);padding:0.55rem;border-radius:4px;font-size:0.75rem;cursor:pointer;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace}
|
||||
#dialog .actions button.cancel{background:transparent;border-color:var(--border);color:var(--muted)}
|
||||
#dialog .actions button:hover{background:rgba(34,211,238,0.25)}
|
||||
.color-row{display:flex;gap:0.4rem;margin-top:0.3rem}
|
||||
.color-row .sw{width:24px;height:24px;border-radius:4px;cursor:pointer;border:2px solid transparent}
|
||||
.color-row .sw.active{border-color:var(--text)}
|
||||
.toast{position:fixed;bottom:1.5rem;right:1.5rem;background:rgba(18,20,24,0.95);border:1px solid var(--accent);color:var(--accent);padding:0.7rem 1.1rem;border-radius:4px;font-family:ui-monospace,monospace;font-size:0.8rem;z-index:300;display:none}
|
||||
.maplibregl-ctrl-group{background:rgba(15,17,21,0.9) !important;border:1px solid var(--border) !important}
|
||||
.maplibregl-ctrl-group button{background-color:transparent !important;filter:invert(85%)}
|
||||
.maplibregl-ctrl-group button:hover{background-color:rgba(34,211,238,0.15) !important}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<aside id="sidebar">
|
||||
<header>
|
||||
<h1>Overlay</h1>
|
||||
<a href="/">← Hub</a>
|
||||
</header>
|
||||
<div class="tool-row">
|
||||
<button class="tool-btn" data-tool="point">+ Point</button>
|
||||
<button class="tool-btn" data-tool="line">+ Line</button>
|
||||
<button class="tool-btn" data-tool="polygon">+ Area</button>
|
||||
<button class="tool-btn" id="printBtn" style="flex-basis:100%">Print Map</button>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<select id="filterCat"><option value="">All Categories</option></select>
|
||||
<input type="text" id="search" placeholder="Filter...">
|
||||
</div>
|
||||
<div class="list" id="list"><div class="empty">Loading...</div></div>
|
||||
</aside>
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
|
||||
<div id="dialog">
|
||||
<h3 id="dlgTitle">New Feature</h3>
|
||||
<label>Name</label>
|
||||
<input type="text" id="fName" placeholder="e.g. Water source #2">
|
||||
<label>Category</label>
|
||||
<select id="fCat">
|
||||
<option>Home</option><option>Routes</option><option>Caches</option><option>Hazards</option>
|
||||
<option>Resources</option><option>Water</option><option>Allies</option><option>Recon</option>
|
||||
<option>Hunting</option><option>Foraging</option><option>Medical</option><option>Comms</option>
|
||||
<option>general</option>
|
||||
</select>
|
||||
<label>Color</label>
|
||||
<div class="color-row" id="colorRow">
|
||||
<div class="sw" data-color="#22d3ee" style="background:#22d3ee"></div>
|
||||
<div class="sw" data-color="#22c55e" style="background:#22c55e"></div>
|
||||
<div class="sw" data-color="#f59e0b" style="background:#f59e0b"></div>
|
||||
<div class="sw" data-color="#dc2626" style="background:#dc2626"></div>
|
||||
<div class="sw" data-color="#a78bfa" style="background:#a78bfa"></div>
|
||||
<div class="sw" data-color="#ec4899" style="background:#ec4899"></div>
|
||||
<div class="sw" data-color="#ffffff" style="background:#ffffff"></div>
|
||||
</div>
|
||||
<label>Notes</label>
|
||||
<textarea id="fNotes" placeholder="Optional notes..."></textarea>
|
||||
<div class="actions">
|
||||
<button class="cancel" id="dlgCancel">Cancel</button>
|
||||
<button id="dlgSave">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script>
|
||||
const protocol = new pmtiles.Protocol();
|
||||
maplibregl.addProtocol("pmtiles", protocol.tile);
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: "map",
|
||||
preserveDrawingBuffer: true,
|
||||
style: {
|
||||
version: 8,
|
||||
glyphs: "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf",
|
||||
sprite: "https://protomaps.github.io/basemaps-assets/sprites/v4/dark",
|
||||
sources: { protomaps: { type: "vector", url: "pmtiles:///maps/base.pmtiles" } },
|
||||
layers: protomaps_themes_base.default("protomaps", "dark")
|
||||
},
|
||||
center: [-96.9469, 32.1268],
|
||||
zoom: 10
|
||||
});
|
||||
map.addControl(new maplibregl.NavigationControl());
|
||||
map.addControl(new maplibregl.ScaleControl());
|
||||
|
||||
const draw = new MapboxDraw({
|
||||
displayControlsDefault: false,
|
||||
styles: defaultDrawStyles()
|
||||
});
|
||||
map.addControl(draw);
|
||||
|
||||
let allFeatures = [];
|
||||
let pendingFeature = null; // GeoJSON feature waiting for save dialog
|
||||
let editingId = null; // id of feature being edited
|
||||
let selectedColor = "#22d3ee";
|
||||
|
||||
function defaultDrawStyles(){
|
||||
return [
|
||||
{"id":"gl-draw-polygon-fill","type":"fill","filter":["all",["==","$type","Polygon"],["!=","mode","static"]],"paint":{"fill-color":"#22d3ee","fill-opacity":0.15}},
|
||||
{"id":"gl-draw-polygon-stroke-active","type":"line","filter":["all",["==","$type","Polygon"]],"paint":{"line-color":"#22d3ee","line-width":2}},
|
||||
{"id":"gl-draw-line-active","type":"line","filter":["==","$type","LineString"],"paint":{"line-color":"#22d3ee","line-width":3}},
|
||||
{"id":"gl-draw-point","type":"circle","filter":["all",["==","$type","Point"],["==","meta","feature"]],"paint":{"circle-radius":6,"circle-color":"#22d3ee"}},
|
||||
{"id":"gl-draw-vertex","type":"circle","filter":["all",["==","meta","vertex"],["==","$type","Point"]],"paint":{"circle-radius":4,"circle-color":"#fff"}}
|
||||
];
|
||||
}
|
||||
|
||||
function toast(msg){
|
||||
const t = document.getElementById("toast");
|
||||
t.textContent = msg; t.style.display = "block";
|
||||
setTimeout(() => t.style.display = "none", 2000);
|
||||
}
|
||||
|
||||
async function loadAll(){
|
||||
const r = await fetch("/overlay/api/list");
|
||||
allFeatures = await r.json();
|
||||
renderFeatures();
|
||||
renderList();
|
||||
populateCats();
|
||||
}
|
||||
|
||||
function renderFeatures(){
|
||||
// Clear existing rendered layers
|
||||
const existing = ["overlay-points","overlay-lines","overlay-polygons","overlay-polygon-stroke","overlay-labels"];
|
||||
existing.forEach(id => {
|
||||
if (map.getLayer(id)) map.removeLayer(id);
|
||||
});
|
||||
if (map.getSource("overlay-data")) map.removeSource("overlay-data");
|
||||
|
||||
const fc = {
|
||||
type: "FeatureCollection",
|
||||
features: allFeatures.map(f => ({
|
||||
type: "Feature",
|
||||
geometry: f.geometry,
|
||||
properties: { id: f.id, name: f.name, color: f.color || "#22d3ee", category: f.category }
|
||||
}))
|
||||
};
|
||||
|
||||
map.addSource("overlay-data", { type: "geojson", data: fc });
|
||||
|
||||
map.addLayer({ id: "overlay-polygons", type: "fill", source: "overlay-data",
|
||||
filter: ["==", "$type", "Polygon"],
|
||||
paint: { "fill-color": ["get", "color"], "fill-opacity": 0.2 }
|
||||
});
|
||||
map.addLayer({ id: "overlay-polygon-stroke", type: "line", source: "overlay-data",
|
||||
filter: ["==", "$type", "Polygon"],
|
||||
paint: { "line-color": ["get", "color"], "line-width": 2 }
|
||||
});
|
||||
map.addLayer({ id: "overlay-lines", type: "line", source: "overlay-data",
|
||||
filter: ["==", "$type", "LineString"],
|
||||
paint: { "line-color": ["get", "color"], "line-width": 3 }
|
||||
});
|
||||
map.addLayer({ id: "overlay-points", type: "circle", source: "overlay-data",
|
||||
filter: ["==", "$type", "Point"],
|
||||
paint: { "circle-radius": 7, "circle-color": ["get", "color"], "circle-stroke-color": "#000", "circle-stroke-width": 1.5 }
|
||||
});
|
||||
map.addLayer({ id: "overlay-labels", type: "symbol", source: "overlay-data",
|
||||
layout: {
|
||||
"text-field": ["get", "name"],
|
||||
"text-size": 11,
|
||||
"text-offset": [0, 1.2],
|
||||
"text-anchor": "top",
|
||||
"text-font": ["Noto Sans Regular"]
|
||||
},
|
||||
paint: { "text-color": "#e5e7eb", "text-halo-color": "#000", "text-halo-width": 1.5 }
|
||||
});
|
||||
}
|
||||
|
||||
function renderList(){
|
||||
const q = document.getElementById("search").value.toLowerCase();
|
||||
const cat = document.getElementById("filterCat").value;
|
||||
const filtered = allFeatures.filter(f => {
|
||||
if (cat && f.category !== cat) return false;
|
||||
if (q) {
|
||||
const s = ((f.name||"")+" "+(f.category||"")+" "+(f.notes||"")).toLowerCase();
|
||||
if (!s.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const el = document.getElementById("list");
|
||||
if (!filtered.length) {
|
||||
el.innerHTML = '<div class="empty">No features. Add one with the tool buttons above.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = filtered.map(f => {
|
||||
const geomType = f.geometry?.type || "?";
|
||||
const icon = geomType === "Point" ? "📍" : geomType === "LineString" ? "📏" : "▱";
|
||||
return '<div class="item" data-id="'+f.id+'" style="border-left-color:'+(f.color||"#22d3ee")+'">' +
|
||||
'<div class="name">'+icon+' '+escapeHtml(f.name||"(unnamed)")+'</div>' +
|
||||
'<div class="meta"><span>'+escapeHtml(f.category||"general")+'</span><span class="del" data-id="'+f.id+'">DEL</span></div>' +
|
||||
'</div>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function populateCats(){
|
||||
const cats = [...new Set(allFeatures.map(f => f.category).filter(Boolean))].sort();
|
||||
const sel = document.getElementById("filterCat");
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">All Categories</option>' + cats.map(c => '<option>'+c+'</option>').join("");
|
||||
sel.value = cur;
|
||||
}
|
||||
|
||||
function escapeHtml(s){
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
// Tool buttons activate draw modes
|
||||
document.querySelectorAll(".tool-btn").forEach(b => {
|
||||
b.addEventListener("click", () => {
|
||||
document.querySelectorAll(".tool-btn").forEach(x => x.classList.remove("active"));
|
||||
b.classList.add("active");
|
||||
const t = b.dataset.tool;
|
||||
if (t === "point") draw.changeMode("draw_point");
|
||||
else if (t === "line") draw.changeMode("draw_line_string");
|
||||
else if (t === "polygon") draw.changeMode("draw_polygon");
|
||||
});
|
||||
});
|
||||
|
||||
// When a feature finishes drawing, open the save dialog
|
||||
map.on("draw.create", e => {
|
||||
pendingFeature = e.features[0];
|
||||
editingId = null;
|
||||
showDialog("New Feature", {});
|
||||
});
|
||||
|
||||
function showDialog(title, vals){
|
||||
document.getElementById("dlgTitle").textContent = title;
|
||||
document.getElementById("fName").value = vals.name || "";
|
||||
document.getElementById("fCat").value = vals.category || "general";
|
||||
document.getElementById("fNotes").value = vals.notes || "";
|
||||
selectedColor = vals.color || "#22d3ee";
|
||||
document.querySelectorAll("#colorRow .sw").forEach(s => {
|
||||
s.classList.toggle("active", s.dataset.color === selectedColor);
|
||||
});
|
||||
document.getElementById("dialog").style.display = "block";
|
||||
document.getElementById("fName").focus();
|
||||
}
|
||||
|
||||
function closeDialog(){
|
||||
document.getElementById("dialog").style.display = "none";
|
||||
if (pendingFeature && !editingId) {
|
||||
draw.delete(pendingFeature.id);
|
||||
}
|
||||
pendingFeature = null;
|
||||
editingId = null;
|
||||
document.querySelectorAll(".tool-btn").forEach(x => x.classList.remove("active"));
|
||||
}
|
||||
|
||||
document.querySelectorAll("#colorRow .sw").forEach(s => {
|
||||
s.addEventListener("click", () => {
|
||||
selectedColor = s.dataset.color;
|
||||
document.querySelectorAll("#colorRow .sw").forEach(x => x.classList.remove("active"));
|
||||
s.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("dlgCancel").addEventListener("click", closeDialog);
|
||||
document.getElementById("dlgSave").addEventListener("click", async () => {
|
||||
const body = {
|
||||
name: document.getElementById("fName").value.trim(),
|
||||
category: document.getElementById("fCat").value,
|
||||
color: selectedColor,
|
||||
notes: document.getElementById("fNotes").value.trim(),
|
||||
geometry: pendingFeature ? pendingFeature.geometry : (editingId ? allFeatures.find(f => f.id === editingId).geometry : null)
|
||||
};
|
||||
if (editingId) {
|
||||
await fetch("/overlay/api/update/" + editingId, {
|
||||
method: "PATCH",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
toast("Updated");
|
||||
} else {
|
||||
await fetch("/overlay/api/add", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
toast("Saved");
|
||||
if (pendingFeature) draw.delete(pendingFeature.id);
|
||||
}
|
||||
document.getElementById("dialog").style.display = "none";
|
||||
pendingFeature = null;
|
||||
editingId = null;
|
||||
document.querySelectorAll(".tool-btn").forEach(x => x.classList.remove("active"));
|
||||
await loadAll();
|
||||
});
|
||||
|
||||
// List item clicks
|
||||
document.getElementById("list").addEventListener("click", async e => {
|
||||
if (e.target.classList.contains("del")) {
|
||||
e.stopPropagation();
|
||||
const id = e.target.dataset.id;
|
||||
if (!confirm("Delete this feature?")) return;
|
||||
await fetch("/overlay/api/delete/" + id, { method: "DELETE" });
|
||||
toast("Deleted");
|
||||
await loadAll();
|
||||
return;
|
||||
}
|
||||
const item = e.target.closest(".item");
|
||||
if (!item) return;
|
||||
const id = item.dataset.id;
|
||||
const f = allFeatures.find(x => x.id === id);
|
||||
if (!f || !f.geometry) return;
|
||||
const coords = flatCoords(f.geometry);
|
||||
if (coords.length === 1) {
|
||||
map.flyTo({ center: coords[0], zoom: 14 });
|
||||
} else {
|
||||
const bounds = coords.reduce((b, c) => b.extend(c), new maplibregl.LngLatBounds(coords[0], coords[0]));
|
||||
map.fitBounds(bounds, { padding: 60, duration: 800 });
|
||||
}
|
||||
});
|
||||
|
||||
// Double-click on an item to edit metadata
|
||||
document.getElementById("list").addEventListener("dblclick", e => {
|
||||
const item = e.target.closest(".item");
|
||||
if (!item) return;
|
||||
const id = item.dataset.id;
|
||||
const f = allFeatures.find(x => x.id === id);
|
||||
if (!f) return;
|
||||
editingId = id;
|
||||
pendingFeature = null;
|
||||
showDialog("Edit Feature", f);
|
||||
});
|
||||
|
||||
// Click a feature on the map -> enter draw direct_select mode for vertex editing
|
||||
map.on("click", "overlay-points", enterEditMode);
|
||||
map.on("click", "overlay-lines", enterEditMode);
|
||||
map.on("click", "overlay-polygons", enterEditMode);
|
||||
|
||||
function enterEditMode(e){
|
||||
const props = e.features[0].properties;
|
||||
const id = props.id;
|
||||
const f = allFeatures.find(x => x.id === id);
|
||||
if (!f) return;
|
||||
// Load the feature into draw as a temporary editable copy
|
||||
const drawId = draw.add({
|
||||
type: "Feature",
|
||||
properties: { _overlayId: id },
|
||||
geometry: f.geometry
|
||||
})[0];
|
||||
draw.changeMode("direct_select", { featureId: drawId });
|
||||
toast("Drag vertices to edit. Click outside when done.");
|
||||
}
|
||||
|
||||
// When a feature is updated via direct_select, save the new geometry
|
||||
map.on("draw.update", async e => {
|
||||
for (const updated of e.features) {
|
||||
const id = updated.properties._overlayId;
|
||||
if (!id) continue;
|
||||
await fetch("/overlay/api/update/" + id, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ geometry: updated.geometry })
|
||||
});
|
||||
}
|
||||
toast("Geometry updated");
|
||||
// Remove the draw layer copy and reload features
|
||||
draw.deleteAll();
|
||||
await loadAll();
|
||||
});
|
||||
|
||||
// If user hits Esc or clicks elsewhere, also clean up draw layer
|
||||
map.on("draw.modechange", e => {
|
||||
if (e.mode === "simple_select" && draw.getAll().features.length > 0) {
|
||||
// Check if any of the in-draw features are edit copies (have _overlayId) and clean them up
|
||||
const feats = draw.getAll().features;
|
||||
const editCopies = feats.filter(f => f.properties && f.properties._overlayId);
|
||||
if (editCopies.length > 0) {
|
||||
// Give the draw.update handler a tick to fire first
|
||||
setTimeout(() => {
|
||||
editCopies.forEach(f => {
|
||||
if (draw.get(f.id)) draw.delete(f.id);
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function flatCoords(geom){
|
||||
if (geom.type === "Point") return [geom.coordinates];
|
||||
if (geom.type === "LineString") return geom.coordinates;
|
||||
if (geom.type === "Polygon") return geom.coordinates[0];
|
||||
return [];
|
||||
}
|
||||
|
||||
document.getElementById("search").addEventListener("input", renderList);
|
||||
document.getElementById("filterCat").addEventListener("change", renderList);
|
||||
|
||||
map.on("load", loadAll);
|
||||
|
||||
document.getElementById("printBtn").addEventListener("click", () => {
|
||||
// Force a render so canvas is up to date
|
||||
map.triggerRepaint();
|
||||
setTimeout(() => {
|
||||
const canvas = map.getCanvas();
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom().toFixed(1);
|
||||
const bounds = map.getBounds();
|
||||
const date = new Date().toLocaleString();
|
||||
const html = '<!DOCTYPE html><html><head><title>Map Print - ' + date + '</title>' +
|
||||
'<style>' +
|
||||
'@page { size: landscape; margin: 0.5in; }' +
|
||||
'body { margin: 0; padding: 1rem; font-family: system-ui, sans-serif; color: #e5e7eb; background: #060708; }' +
|
||||
'.header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.5rem; border-bottom: 2px solid #22d3ee; margin-bottom: 0.75rem; }' +
|
||||
'.header h1 { margin: 0; font-size: 16pt; letter-spacing: 0.2em; color: #22d3ee; text-shadow: 0 0 12px rgba(34,211,238,0.4); }' +
|
||||
'.header .meta { font-family: ui-monospace, monospace; font-size: 9pt; text-align: right; color: #9ca3af; }' +
|
||||
'img { width: 100%; max-height: 75vh; object-fit: contain; border: 1px solid rgba(80,90,100,0.3); border-radius: 4px; }' +
|
||||
'.footer { margin-top: 0.5rem; font-family: ui-monospace, monospace; font-size: 8pt; color: #9ca3af; display: flex; justify-content: space-between; }' +
|
||||
'.btns { padding: 0.75rem 0; text-align: center; }' +
|
||||
'.btns button { background: rgba(18,20,24,0.9); color: #22d3ee; border: 1px solid rgba(34,211,238,0.4); padding: 0.5rem 1.2rem; margin: 0 0.25rem; cursor: pointer; font-family: ui-monospace, monospace; font-size: 10pt; letter-spacing: 0.1em; text-transform: uppercase; border-radius: 4px; }' +
|
||||
'@media print { .btns { display: none; } body { background: #fff !important; color: #000 !important; } .header h1 { color: #000 !important; text-shadow: none !important; } .header { border-bottom-color: #000 !important; } .header .meta, .footer { color: #555 !important; } }' +
|
||||
'</style></head><body>' +
|
||||
'<div class="btns"><button onclick="window.print()">Print</button><button onclick="window.close()">Close</button><a download="map-' + Date.now() + '.png" href="' + dataUrl + '"><button>Save PNG</button></a></div>' +
|
||||
'<div class="header">' +
|
||||
'<h1>THE DARK ELITE // MAP</h1>' +
|
||||
'<div class="meta">' + date + '<br>Zoom: ' + zoom + ' · Center: ' + center.lng.toFixed(4) + ", " + center.lat.toFixed(4) + '</div>' +
|
||||
'</div>' +
|
||||
'<img src="' + dataUrl + '">' +
|
||||
'<div class="footer">' +
|
||||
'<span>SW: ' + bounds.getSouthWest().lng.toFixed(4) + ", " + bounds.getSouthWest().lat.toFixed(4) + '</span>' +
|
||||
'<span>NE: ' + bounds.getNorthEast().lng.toFixed(4) + ", " + bounds.getNorthEast().lat.toFixed(4) + '</span>' +
|
||||
'</div>' +
|
||||
'</body></html>';
|
||||
const w = window.open("", "_blank");
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
}, 200);
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -0,0 +1,95 @@
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException, Form
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import os, json, uuid, shutil, re
|
||||
from datetime import datetime
|
||||
|
||||
PDF_DIR = "/files"
|
||||
META = "/files/.metadata.json"
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
def load_meta():
|
||||
if os.path.exists(META):
|
||||
with open(META) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_meta(m):
|
||||
with open(META, "w") as f:
|
||||
json.dump(m, f, indent=2)
|
||||
|
||||
def safe_name(name):
|
||||
return re.sub(r'[^\w\-. ]', '_', name)[:200]
|
||||
|
||||
@app.get("/api/list")
|
||||
def list_pdfs():
|
||||
meta = load_meta()
|
||||
items = []
|
||||
for fid, info in meta.items():
|
||||
path = os.path.join(PDF_DIR, info["filename"])
|
||||
if os.path.exists(path):
|
||||
items.append({
|
||||
"id": fid,
|
||||
"title": info.get("title", info["filename"]),
|
||||
"filename": info["filename"],
|
||||
"tag": info.get("tag", ""),
|
||||
"size": os.path.getsize(path),
|
||||
"uploaded": info.get("uploaded", ""),
|
||||
})
|
||||
items.sort(key=lambda x: x["uploaded"], reverse=True)
|
||||
return items
|
||||
|
||||
@app.post("/api/upload")
|
||||
async def upload(file: UploadFile = File(...), title: str = Form(""), tag: str = Form("")):
|
||||
if not file.filename.lower().endswith(".pdf"):
|
||||
raise HTTPException(400, "PDFs only")
|
||||
fid = uuid.uuid4().hex[:12]
|
||||
fname = safe_name(file.filename)
|
||||
path = os.path.join(PDF_DIR, f"{fid}_{fname}")
|
||||
with open(path, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
meta = load_meta()
|
||||
meta[fid] = {
|
||||
"filename": f"{fid}_{fname}",
|
||||
"title": title or fname.rsplit(".", 1)[0],
|
||||
"tag": tag,
|
||||
"uploaded": datetime.utcnow().isoformat(),
|
||||
}
|
||||
save_meta(meta)
|
||||
return {"id": fid}
|
||||
|
||||
@app.delete("/api/delete/{fid}")
|
||||
def delete(fid: str):
|
||||
meta = load_meta()
|
||||
if fid not in meta:
|
||||
raise HTTPException(404)
|
||||
path = os.path.join(PDF_DIR, meta[fid]["filename"])
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
del meta[fid]
|
||||
save_meta(meta)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/view/{fid}")
|
||||
def view(fid: str):
|
||||
meta = load_meta()
|
||||
if fid not in meta:
|
||||
raise HTTPException(404)
|
||||
path = os.path.join(PDF_DIR, meta[fid]["filename"])
|
||||
return FileResponse(path, media_type="application/pdf")
|
||||
|
||||
@app.patch("/api/update/{fid}")
|
||||
async def update(fid: str, tag: str = Form(None), title: str = Form(None)):
|
||||
meta = load_meta()
|
||||
if fid not in meta:
|
||||
raise HTTPException(404)
|
||||
if tag is not None:
|
||||
meta[fid]["tag"] = tag
|
||||
if title is not None and title.strip():
|
||||
meta[fid]["title"] = title.strip()
|
||||
save_meta(meta)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory="/static", html=True), name="static")
|
||||
@@ -0,0 +1,205 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>PDF Library - The Dark Elite</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
:root { --bg:#060708; --panel:#0f1115; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; --danger:#dc2626; }
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;min-height:100vh}
|
||||
.container{max-width:1200px;margin:0 auto;padding:2rem}
|
||||
header{display:flex;justify-content:space-between;align-items:center;padding-bottom:1.5rem;border-bottom:1px solid var(--border);margin-bottom:2rem}
|
||||
header h1{margin:0;font-size:1.5rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent);text-shadow:0 0 12px rgba(34,211,238,0.4)}
|
||||
header a{color:var(--muted);text-decoration:none;font-size:0.85rem}
|
||||
header a:hover{color:var(--accent)}
|
||||
.dropzone{border:2px dashed rgba(34,211,238,0.4);border-radius:8px;padding:2rem;text-align:center;background:linear-gradient(135deg,rgba(18,20,24,0.7),rgba(10,11,13,0.85));margin-bottom:2rem;cursor:pointer;transition:all 0.15s}
|
||||
.dropzone:hover,.dropzone.drag{border-color:var(--accent);box-shadow:0 0 20px rgba(34,211,238,0.15)}
|
||||
.dropzone p{margin:0.3rem 0;color:var(--muted);font-size:0.9rem}
|
||||
.dropzone .big{color:var(--accent);font-size:1.1rem;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace}
|
||||
.dropzone input[type=file]{display:none}
|
||||
.fields{display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;margin-top:1rem}
|
||||
.fields input{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.5rem 0.8rem;border-radius:4px;font-size:0.85rem}
|
||||
.fields input:focus{outline:none;border-color:var(--accent)}
|
||||
.section-title{font-size:0.75rem;letter-spacing:0.25em;text-transform:uppercase;color:var(--accent);border-left:3px solid var(--accent);padding-left:0.75rem;margin-bottom:1rem;display:flex;justify-content:space-between;align-items:center}
|
||||
.search{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.4rem 0.7rem;border-radius:4px;font-size:0.8rem;font-family:ui-monospace,monospace}
|
||||
.search:focus{outline:none;border-color:var(--accent)}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem}
|
||||
.card{background:linear-gradient(135deg,rgba(18,20,24,0.9),rgba(10,11,13,0.95));border:1px solid var(--border);border-radius:6px;padding:1rem;transition:all 0.15s;position:relative;overflow:hidden}
|
||||
.card:hover{border-color:rgba(34,211,238,0.5);transform:translateY(-2px);box-shadow:0 0 25px rgba(34,211,238,0.18)}
|
||||
.card .title{font-size:0.95rem;color:var(--text);margin-bottom:0.3rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-weight:500}
|
||||
.card .meta{font-size:0.7rem;color:var(--muted);font-family:ui-monospace,monospace;letter-spacing:0.05em}
|
||||
.card .tag{display:inline-block;background:rgba(34,211,238,0.12);border:1px solid rgba(34,211,238,0.3);color:rgb(165,235,247);font-size:0.65rem;padding:0.15rem 0.5rem;border-radius:3px;margin-top:0.5rem;font-family:ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.1em}
|
||||
.card .actions{display:flex;gap:0.4rem;margin-top:0.7rem}
|
||||
.card .actions a,.card .actions button{flex:1;background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.35rem;border-radius:3px;font-size:0.7rem;cursor:pointer;text-align:center;text-decoration:none;letter-spacing:0.1em;text-transform:uppercase}
|
||||
.card .actions a:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.card .actions button:hover{border-color:var(--danger);color:var(--danger)}
|
||||
.empty{color:var(--muted);text-align:center;padding:2rem;font-style:italic}
|
||||
.toast{position:fixed;bottom:2rem;right:2rem;background:rgba(18,20,24,0.95);border:1px solid var(--accent);color:var(--accent);padding:0.8rem 1.2rem;border-radius:4px;font-family:ui-monospace,monospace;font-size:0.85rem;z-index:100;display:none}
|
||||
|
||||
.tag-row{margin-top:0.5rem;min-height:1.4rem}
|
||||
.tag-edit{display:inline-block;background:rgba(34,211,238,0.12);border:1px solid rgba(34,211,238,0.3);color:rgb(165,235,247);font-size:0.65rem;padding:0.15rem 0.5rem;border-radius:3px;font-family:ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.1em;cursor:pointer;transition:all 0.15s}
|
||||
.tag-edit:empty::before,.tag-edit:hover{border-color:rgba(34,211,238,0.7);color:var(--accent)}
|
||||
.tag-edit.empty{background:transparent;border-style:dashed;color:var(--muted)}
|
||||
.tag-edit input{background:rgba(10,11,13,0.9);border:1px solid var(--accent);color:var(--accent);font:inherit;padding:0;width:8rem;outline:none;text-transform:uppercase}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div><h1>PDF Library</h1></div>
|
||||
<a href="/">← Hub</a>
|
||||
</header>
|
||||
|
||||
<div class="dropzone" id="dz">
|
||||
<p class="big">Drop PDFs here or click to upload</p>
|
||||
<p>Supports drag-drop, multiple files</p>
|
||||
<input type="file" id="fileInput" accept=".pdf" multiple>
|
||||
<div class="fields">
|
||||
<input type="text" id="titleInput" placeholder="Title (optional - uses filename)">
|
||||
<input type="text" id="tagInput" placeholder="Tag (medical, repair, etc.)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">
|
||||
Library
|
||||
<div style="display:flex;gap:0.5rem;align-items:center">
|
||||
<select class="search" id="sortInput" style="font-family:ui-monospace,monospace">
|
||||
<option value="date-desc">Newest First</option>
|
||||
<option value="date-asc">Oldest First</option>
|
||||
<option value="title-asc">Title A-Z</option>
|
||||
<option value="title-desc">Title Z-A</option>
|
||||
<option value="size-desc">Size (Largest)</option>
|
||||
<option value="size-asc">Size (Smallest)</option>
|
||||
<option value="tag-asc">Tag A-Z</option>
|
||||
</select>
|
||||
<input type="text" class="search" id="searchInput" placeholder="Filter...">
|
||||
</div>
|
||||
</div>
|
||||
<div id="grid" class="grid"><div class="empty">Loading...</div></div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script>
|
||||
let allItems = [];
|
||||
|
||||
function fmtSize(b){
|
||||
if(b<1024) return b+" B";
|
||||
if(b<1048576) return (b/1024).toFixed(1)+" KB";
|
||||
if(b<1073741824) return (b/1048576).toFixed(1)+" MB";
|
||||
return (b/1073741824).toFixed(2)+" GB";
|
||||
}
|
||||
function fmtDate(s){
|
||||
if(!s) return "";
|
||||
return new Date(s).toLocaleDateString();
|
||||
}
|
||||
|
||||
function toast(msg){
|
||||
const t=document.getElementById("toast");
|
||||
t.textContent=msg;
|
||||
t.style.display="block";
|
||||
setTimeout(()=>t.style.display="none",2500);
|
||||
}
|
||||
|
||||
async function loadList(){
|
||||
const r=await fetch("api/list");
|
||||
allItems=await r.json();
|
||||
render();
|
||||
}
|
||||
|
||||
function render(){
|
||||
const q=document.getElementById("searchInput").value.toLowerCase();
|
||||
const sort=document.getElementById("sortInput").value;
|
||||
let items=allItems.filter(i=>!q || i.title.toLowerCase().includes(q) || (i.tag||"").toLowerCase().includes(q));
|
||||
items.sort((a,b)=>{
|
||||
switch(sort){
|
||||
case "date-desc": return (b.uploaded||"").localeCompare(a.uploaded||"");
|
||||
case "date-asc": return (a.uploaded||"").localeCompare(b.uploaded||"");
|
||||
case "title-asc": return a.title.localeCompare(b.title);
|
||||
case "title-desc":return b.title.localeCompare(a.title);
|
||||
case "size-desc": return b.size-a.size;
|
||||
case "size-asc": return a.size-b.size;
|
||||
case "tag-asc": return (a.tag||"").localeCompare(b.tag||"") || a.title.localeCompare(b.title);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const grid=document.getElementById("grid");
|
||||
if(!items.length){
|
||||
grid.innerHTML='<div class="empty">No PDFs yet. Upload some above.</div>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML=items.map(i=>`
|
||||
<div class="card">
|
||||
<div class="title">${i.title}</div>
|
||||
<div class="meta">${fmtSize(i.size)} • ${fmtDate(i.uploaded)}</div>
|
||||
<div class="tag-row"><span class="tag-edit ${i.tag?'':'empty'}" onclick="editTag('${i.id}', this)">${i.tag||"+ add tag"}</span></div>
|
||||
<div class="actions">
|
||||
<a href="view/${i.id}" target="_blank">View</a>
|
||||
<button onclick="del('${i.id}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
async function del(fid){
|
||||
if(!confirm("Delete this PDF?")) return;
|
||||
await fetch("api/delete/"+fid,{method:"DELETE"});
|
||||
toast("Deleted");
|
||||
loadList();
|
||||
}
|
||||
|
||||
async function upload(files){
|
||||
for(const file of files){
|
||||
if(!file.name.toLowerCase().endsWith(".pdf")){ toast("Skipped: "+file.name); continue; }
|
||||
const fd=new FormData();
|
||||
fd.append("file",file);
|
||||
fd.append("title",document.getElementById("titleInput").value);
|
||||
fd.append("tag",document.getElementById("tagInput").value);
|
||||
toast("Uploading "+file.name+"...");
|
||||
const r=await fetch("api/upload",{method:"POST",body:fd});
|
||||
if(r.ok) toast("Uploaded "+file.name);
|
||||
else toast("Failed: "+file.name);
|
||||
}
|
||||
document.getElementById("titleInput").value="";
|
||||
document.getElementById("tagInput").value="";
|
||||
loadList();
|
||||
}
|
||||
|
||||
const dz=document.getElementById("dz");
|
||||
const fi=document.getElementById("fileInput");
|
||||
dz.addEventListener("click",e=>{ if(e.target.tagName!=="INPUT") fi.click(); });
|
||||
fi.addEventListener("change",e=>upload(e.target.files));
|
||||
dz.addEventListener("dragover",e=>{ e.preventDefault(); dz.classList.add("drag"); });
|
||||
dz.addEventListener("dragleave",()=>dz.classList.remove("drag"));
|
||||
dz.addEventListener("drop",e=>{ e.preventDefault(); dz.classList.remove("drag"); upload(e.dataTransfer.files); });
|
||||
document.getElementById("searchInput").addEventListener("input",render);
|
||||
document.getElementById("sortInput").addEventListener("change",render);
|
||||
|
||||
|
||||
async function editTag(fid, el){
|
||||
if(el.querySelector("input")) return;
|
||||
const current = el.textContent === "+ add tag" ? "" : el.textContent;
|
||||
el.innerHTML = '<input type="text" value="'+current+'" />';
|
||||
const input = el.querySelector("input");
|
||||
input.focus();
|
||||
input.select();
|
||||
const save = async () => {
|
||||
const val = input.value.trim();
|
||||
const fd = new FormData();
|
||||
fd.append("tag", val);
|
||||
await fetch("api/update/"+fid, {method:"PATCH", body: fd});
|
||||
toast(val ? "Tag set: "+val : "Tag cleared");
|
||||
loadList();
|
||||
};
|
||||
input.addEventListener("blur", save);
|
||||
input.addEventListener("keydown", e => {
|
||||
if(e.key === "Enter") input.blur();
|
||||
if(e.key === "Escape"){ input.value = current; input.blur(); }
|
||||
});
|
||||
}
|
||||
|
||||
loadList();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
import os, json, uuid, re
|
||||
from datetime import datetime
|
||||
|
||||
PDF_DIR = "/opt/hub/pdfs/files"
|
||||
META = os.path.join(PDF_DIR, ".metadata.json")
|
||||
|
||||
TAG_RULES = [
|
||||
(r'fm[-_ ]?\d|atp[-_ ]?\d|tm[-_ ]?\d|tc[-_ ]?\d|army|military|combat|tactical|sniper|infantry|guerrilla|special.forces', 'defense'),
|
||||
(r'medic|first.aid|surgery|trauma|nursing|hesperian|midwif|dentist|disease|emergency.med|tccc|where.there.is.no.doctor', 'medical'),
|
||||
(r'survival|prepper|shtf|bug.out|wilderness|evasion', 'survival'),
|
||||
(r'radio|ham|antenna|arrl|fcc|comms|aprs|signal', 'comms'),
|
||||
(r'food|cook|cann|preserv|recipe|kitchen|baking|ferment', 'food'),
|
||||
(r'farm|garden|crop|seed|livestock|chicken|poultry|cattle|sheep|goat|beekeep|honey|agriculture|permaculture', 'agriculture'),
|
||||
(r'vet|animal|dog|cat|horse|pig|pet', 'veterinary'),
|
||||
(r'navig|map|compass|gps|bowditch|topo', 'navigation'),
|
||||
(r'solar|wind|battery|grid|power|nrel|energy|generator', 'energy'),
|
||||
(r'electric|plumb|carpent|hvac|welding|machinist|trade|repair|audel', 'trades'),
|
||||
(r'chem|biolog|physic|crc.handbook|merck.index', 'chemistry'),
|
||||
(r'engineer|mechanic|machine|handbook', 'engineering'),
|
||||
(r'law|legal|constitution|statute|code|court|right', 'law'),
|
||||
(r'finance|tax|irs|money|invest|insurance', 'finance'),
|
||||
(r'history|war|civil|world.war|geopolit', 'history'),
|
||||
]
|
||||
|
||||
def infer_tag(filename):
|
||||
name = filename.lower()
|
||||
for pattern, tag in TAG_RULES:
|
||||
if re.search(pattern, name):
|
||||
return tag
|
||||
return 'general'
|
||||
|
||||
def clean_title(filename):
|
||||
name = filename.rsplit('.', 1)[0]
|
||||
name = re.sub(r'[_-]+', ' ', name).strip()
|
||||
return name[:200]
|
||||
|
||||
def main():
|
||||
if os.path.exists(META):
|
||||
with open(META) as f:
|
||||
meta = json.load(f)
|
||||
else:
|
||||
meta = {}
|
||||
|
||||
registered = {info['filename'] for info in meta.values()}
|
||||
added = 0
|
||||
skipped = 0
|
||||
for fname in sorted(os.listdir(PDF_DIR)):
|
||||
if not fname.lower().endswith('.pdf'): continue
|
||||
if fname.startswith('.'): continue
|
||||
if fname in registered:
|
||||
skipped += 1
|
||||
continue
|
||||
path = os.path.join(PDF_DIR, fname)
|
||||
if os.path.getsize(path) < 50000: continue
|
||||
fid = uuid.uuid4().hex[:12]
|
||||
meta[fid] = {
|
||||
'filename': fname,
|
||||
'title': clean_title(fname),
|
||||
'tag': infer_tag(fname),
|
||||
'uploaded': datetime.utcnow().isoformat(),
|
||||
}
|
||||
added += 1
|
||||
|
||||
with open(META, 'w') as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
|
||||
print(f"Added: {added} Already registered: {skipped} Total in library: {len(meta)}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/bin/bash
|
||||
# COMPREHENSIVE Reference PDF Library Seeder
|
||||
# Covers Trades, Agriculture, Energy, Wilderness, Food, Chemistry,
|
||||
# Mechanical, Comms, Medical, Military, Gov/Law, Navigation, Skills,
|
||||
# Finance, Pet/Livestock
|
||||
set -u
|
||||
TMP=/tmp/pdf-seed-all
|
||||
API=http://localhost/pdfs/api/upload
|
||||
mkdir -p "$TMP"
|
||||
|
||||
PDFS=(
|
||||
# ========== TRADES & REPAIR ==========
|
||||
"https://archive.org/download/bwb_Y0-BMG-117/bwb_Y0-BMG-117.pdf|Audels Plumbers and Steam Fitters Guide #3 (1925)|trades"
|
||||
"https://archive.org/download/cu31924004933945/cu31924004933945.pdf|Henleys Twentieth Century Book of Formulas (1909)|trades"
|
||||
"https://archive.org/download/theboymechanicvo12655gut/theboymechanicvo12655gut.pdf|The Boy Mechanic Vol 1 (1913)|trades"
|
||||
"https://archive.org/download/the-boy-mechanic-book-2-1000-things-for-unknown/The%20Boy%20Mechanic%2C%20Book%202%20%281915%29%20-%201%2C000%20Things%20for%20Boys%20to%20Do.pdf|The Boy Mechanic Vol 2 (1915)|trades"
|
||||
"https://rexresearch1.com/AudelManuals/AudelPipefittersWeldersPocketManual.pdf|Audel Pipefitters and Welders Pocket Manual|trades"
|
||||
|
||||
# ========== AGRICULTURE / SELF-SUFFICIENCY ==========
|
||||
# USDA Farmers Bulletins must be sourced individually; placing 1 sample
|
||||
"https://naldc.nal.usda.gov/download/CAT74420061/PDF|USDA Farmers Bulletin No 1186 - Pork on the Farm|agriculture"
|
||||
|
||||
# ========== ENERGY / OFF-GRID ==========
|
||||
"https://www.nrel.gov/docs/fy20osti/76011.pdf|NREL Off-Grid PV System Design Sizing Guide|energy"
|
||||
|
||||
# ========== WILDERNESS / OUTDOOR ==========
|
||||
# Boy Scout Handbook 1911 is text-only on Gutenberg; sourcing alternate scan
|
||||
"https://archive.org/download/boyscoutshandboo00boys/boyscoutshandboo00boys.pdf|Boy Scouts Handbook First Edition (1911)|wilderness"
|
||||
|
||||
# ========== FOOD / COOKING / PRESERVATION ==========
|
||||
"https://archive.org/download/bostoncookingsc00farm/bostoncookingsc00farm.pdf|Boston Cooking School Cook Book - Fannie Farmer (1918)|food"
|
||||
"https://nchfp.uga.edu/how/dry/food_drying_what_you_need_to_know.pdf|USDA NCHFP - Food Drying What You Need to Know|food"
|
||||
|
||||
# ========== CHEMISTRY / SCIENCE ==========
|
||||
"https://archive.org/download/CRCHandbookOfChemistryAndPhysics97thEdition2016/CRC%20Handbook%20of%20Chemistry%20and%20Physics%2C%2097th%20Edition.pdf|CRC Handbook of Chemistry and Physics 97th Edition (2016)|chemistry"
|
||||
|
||||
# ========== MECHANICAL / ENGINEERING ==========
|
||||
# Machinery's Handbook 6th already added in original batch
|
||||
|
||||
# ========== VEHICLE (manual sourcing recommended) ==========
|
||||
# Ram 1500 / Cub Cadet FSMs typically need OEM login - skipped
|
||||
|
||||
# ========== COMMUNICATIONS ==========
|
||||
# ARRL/Antenna books are paid; skipped
|
||||
|
||||
# ========== MEDICAL DEPTH ==========
|
||||
"https://ia801609.us.archive.org/9/items/civilian-and-non-civilian-medical-guides/Ranger%20Medic%20Handbook%20%282007%29.pdf|Ranger Medic Handbook (2007)|medical"
|
||||
"https://archive.org/download/SOFMH2001/SOFMH2001.pdf|Special Operations Forces Medical Handbook (2001)|medical"
|
||||
"https://archive.org/download/where-there-is-no-vet/where-there-is-no-vet.pdf|Where There Is No Vet|veterinary"
|
||||
|
||||
# ========== MILITARY / TACTICAL ==========
|
||||
"https://archive.org/download/Jungle_Operations_FM_90-5/Jungle_Operations_FM_90-5.pdf|FM 90-5 Jungle Operations (1982)|defense"
|
||||
"https://ia801302.us.archive.org/16/items/FM31_21_1961/FM31_21_1961.pdf|FM 31-21 Guerrilla Warfare and Special Forces Operations (1961)|defense"
|
||||
"https://archive.org/download/usa-tm-31-210-improvised-munitions-handbook/USA%20TM%2031%20210%20Improvised%20Munitions%20Handbook.pdf|TM 31-210 Improvised Munitions Handbook (1969)|defense"
|
||||
|
||||
# ========== GOVERNMENT / LAW ==========
|
||||
"https://archive.org/download/TheFederalistPapers_201607/The%20Federalist%20Papers.pdf|The Federalist Papers|law"
|
||||
"https://archive.org/download/roberts-rules-of-order/roberts-rules-of-order.pdf|Roberts Rules of Order (Parliamentary Procedure)|law"
|
||||
"https://statutes.capitol.texas.gov/docs/sdocs/penalcode.pdf|Texas Penal Code|law"
|
||||
"https://statutes.capitol.texas.gov/docs/sdocs/familycode.pdf|Texas Family Code|law"
|
||||
"https://statutes.capitol.texas.gov/docs/sdocs/propertycode.pdf|Texas Property Code|law"
|
||||
"https://statutes.capitol.texas.gov/docs/sdocs/cn.pdf|Texas Constitution|law"
|
||||
|
||||
# ========== NAVIGATION DEPTH ==========
|
||||
# Bowditch V1+V2 already included; USGS topo symbols below
|
||||
"https://pubs.usgs.gov/gip/TopographicMapSymbols/topomapsymbols.pdf|USGS Topographic Map Symbols|navigation"
|
||||
|
||||
# ========== SPECIFIC SKILLS ==========
|
||||
# Welding/knots/blacksmithing - most under restricted access, manual sourcing
|
||||
|
||||
# ========== FINANCE / PRACTICAL LIFE ==========
|
||||
"https://www.irs.gov/pub/irs-pdf/p17.pdf|IRS Publication 17 - Your Federal Income Tax|finance"
|
||||
|
||||
# ========== PETS / LIVESTOCK ==========
|
||||
# Where There Is No Vet already in medical above
|
||||
)
|
||||
|
||||
ok=0; fail=0; total=${#PDFS[@]}; i=0
|
||||
for entry in "${PDFS[@]}"; do
|
||||
i=$((i+1))
|
||||
IFS='|' read -r url title tag <<< "$entry"
|
||||
fname=$(echo "$title" | tr -c 'A-Za-z0-9._-' '_' | head -c 100).pdf
|
||||
out="$TMP/$fname"
|
||||
echo ""
|
||||
echo "==> [$i/$total] [$title]"
|
||||
if wget -q --show-progress -U "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" --tries=2 --timeout=120 -O "$out" "$url"; then
|
||||
sz_bytes=$(stat -c%s "$out" 2>/dev/null || echo 0)
|
||||
if [ "$sz_bytes" -gt 100000 ] && file "$out" | grep -qi "PDF"; then
|
||||
sz=$(du -h "$out" | cut -f1)
|
||||
curl -s -X POST "$API" -F "file=@$out;type=application/pdf" -F "title=$title" -F "tag=$tag" > /dev/null
|
||||
echo " ✓ uploaded ($sz)"
|
||||
ok=$((ok+1))
|
||||
else
|
||||
echo " ✗ skipped (not a valid PDF, $sz_bytes bytes)"
|
||||
fail=$((fail+1))
|
||||
fi
|
||||
rm -f "$out"
|
||||
else
|
||||
echo " ✗ download failed"
|
||||
fail=$((fail+1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "================================"
|
||||
echo "OK: $ok Failed: $fail Total: $total"
|
||||
echo "================================"
|
||||
rm -rf "$TMP"
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
set -u
|
||||
TMP=/tmp/pdf-seed-fix
|
||||
API=http://localhost/pdfs/api/upload
|
||||
mkdir -p "$TMP"
|
||||
|
||||
PDFS=(
|
||||
"https://ia801301.us.archive.org/5/items/WhereWomenHaveNoDoctor/18.HersperianFoundation-WhereWomenHaveNoDoctor.pdf|Where Women Have No Doctor (Hesperian)|medical"
|
||||
"https://archive.org/download/WhereThereIsNoDoctor-English-DavidWerner/WhereThereIsNoDoctor-English-DavidWerner.pdf|Where There Is No Doctor (Hesperian)|medical"
|
||||
"https://ia801500.us.archive.org/28/items/MManuals/Fm5-31Boobytraps.pdf|FM 5-31 Boobytraps (1965)|defense"
|
||||
"https://archive.org/download/milmanual-fm-3-25.26-map-reading-and-land-navigation/fm_3-25.26_map_reading_and_land_navigation.pdf|FM 3-25.26 Map Reading and Land Navigation|navigation"
|
||||
"https://archive.org/download/milmanual-fm-23-10-sniper-training/fm_23-10_sniper_training.pdf|FM 23-10 Sniper Training|defense"
|
||||
"https://archive.org/download/milmanual-fm-21-150-combatives/fm_21-150_combatives.pdf|FM 21-150 Combatives|defense"
|
||||
"https://archive.org/download/milmanual-fm-3-06.11-combined-arms-operations-in-urban-terrain/fm_3-06.11_combined_arms_operations_in_urban_terrain.pdf|FM 3-06.11 Combined Arms Urban Terrain|defense"
|
||||
"https://armypubs.army.mil/epubs/DR_pubs/DR_a/ARN36119-TC_3-22.9-000-WEB-1.pdf|TC 3-22.9 Rifle and Carbine Marksmanship (v2)|defense"
|
||||
)
|
||||
|
||||
ok=0; fail=0; total=${#PDFS[@]}; i=0
|
||||
for entry in "${PDFS[@]}"; do
|
||||
i=$((i+1))
|
||||
IFS='|' read -r url title tag <<< "$entry"
|
||||
fname=$(echo "$title" | tr -c 'A-Za-z0-9._-' '_' | head -c 100).pdf
|
||||
out="$TMP/$fname"
|
||||
echo ""
|
||||
echo "==> [$i/$total] [$title]"
|
||||
if wget -q --show-progress -U "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" --tries=2 --timeout=60 -O "$out" "$url"; then
|
||||
sz_bytes=$(stat -c%s "$out" 2>/dev/null || echo 0)
|
||||
if [ "$sz_bytes" -gt 100000 ] && file "$out" | grep -qi "PDF"; then
|
||||
sz=$(du -h "$out" | cut -f1)
|
||||
curl -s -X POST "$API" -F "file=@$out;type=application/pdf" -F "title=$title" -F "tag=$tag" > /dev/null
|
||||
echo " ✓ uploaded ($sz)"
|
||||
ok=$((ok+1))
|
||||
else
|
||||
echo " ✗ skipped (not a valid PDF, $sz_bytes bytes)"
|
||||
fail=$((fail+1))
|
||||
fi
|
||||
rm -f "$out"
|
||||
else
|
||||
echo " ✗ download failed"
|
||||
fail=$((fail+1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "================================"
|
||||
echo "OK: $ok Failed: $fail Total: $total"
|
||||
echo "================================"
|
||||
rm -rf "$TMP"
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
set -u
|
||||
TMP=/tmp/pdf-seed-fix2
|
||||
API=http://localhost/pdfs/api/upload
|
||||
mkdir -p "$TMP"
|
||||
|
||||
PDFS=(
|
||||
"https://archive.org/download/fm21-150-combatives/fm21-150-combatives.pdf|FM 21-150 Combatives (1992)|defense"
|
||||
"https://archive.org/download/WhereThereIsNoDoctor-English-DavidWerner/WhereThereIsNoDoctor-English-DavidWerner.pdf|Where There Is No Doctor (Hesperian)|medical"
|
||||
"https://www.globalsecurity.org/military/library/policy/army/fm/3-22-9/fm3-22-9_c1_2011.pdf|FM 3-22.9 Rifle Marksmanship M16/M4 (2011)|defense"
|
||||
)
|
||||
|
||||
ok=0; fail=0; total=${#PDFS[@]}; i=0
|
||||
for entry in "${PDFS[@]}"; do
|
||||
i=$((i+1))
|
||||
IFS='|' read -r url title tag <<< "$entry"
|
||||
fname=$(echo "$title" | tr -c 'A-Za-z0-9._-' '_' | head -c 100).pdf
|
||||
out="$TMP/$fname"
|
||||
echo ""
|
||||
echo "==> [$i/$total] [$title]"
|
||||
if wget -q --show-progress -U "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" --tries=2 --timeout=120 -O "$out" "$url"; then
|
||||
sz_bytes=$(stat -c%s "$out" 2>/dev/null || echo 0)
|
||||
if [ "$sz_bytes" -gt 100000 ] && file "$out" | grep -qi "PDF"; then
|
||||
sz=$(du -h "$out" | cut -f1)
|
||||
curl -s -X POST "$API" -F "file=@$out;type=application/pdf" -F "title=$title" -F "tag=$tag" > /dev/null
|
||||
echo " ✓ uploaded ($sz)"
|
||||
ok=$((ok+1))
|
||||
else
|
||||
echo " ✗ skipped (not a valid PDF, $sz_bytes bytes)"
|
||||
fail=$((fail+1))
|
||||
fi
|
||||
rm -f "$out"
|
||||
else
|
||||
echo " ✗ download failed"
|
||||
fail=$((fail+1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "================================"
|
||||
echo "OK: $ok Failed: $fail Total: $total"
|
||||
echo "================================"
|
||||
rm -rf "$TMP"
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
# Reference PDF Library - Full SHTF/Prep Collection
|
||||
set -u
|
||||
TMP=/tmp/pdf-seed
|
||||
API=http://localhost/pdfs/api/upload
|
||||
mkdir -p "$TMP"
|
||||
|
||||
PDFS=(
|
||||
# ========== MEDICAL - HESPERIAN (full books from archive.org mirror) ==========
|
||||
"https://archive.org/download/strategic_intelligence_network/medical/hesperian_-_where_there_is_no_doctor_2017_full_book.pdf|Where There Is No Doctor (Hesperian 2017)|medical"
|
||||
"https://archive.org/download/strategic_intelligence_network/medical/hesperian_-_where_there_is_no_dentist_2012_full_book.pdf|Where There Is No Dentist (Hesperian)|medical"
|
||||
"https://archive.org/download/strategic_intelligence_network/medical/hesperian_-_a_book_for_the_midwives_2013_full_book.pdf|A Book for Midwives (Hesperian)|medical"
|
||||
"https://archive.org/download/strategic_intelligence_network/medical/hesperian_-_disabled_village_children_2009_full_book.pdf|Disabled Village Children (Hesperian)|medical"
|
||||
"https://archive.org/download/strategic_intelligence_network/medical/hesperian_-_helping_children_who_are_deaf_2004_full_book.pdf|Helping Children Who Are Deaf (Hesperian)|medical"
|
||||
"https://archive.org/download/strategic_intelligence_network/medical/hesperian_-_where_women_have_no_doctor_full_book.pdf|Where Women Have No Doctor (Hesperian)|medical"
|
||||
|
||||
# ========== MEDICAL - MILITARY / TACTICAL ==========
|
||||
"https://tccc.org.ua/files/downloads/clinical-guidelines-2024-en.pdf|TCCC Guidelines 2024 (Tactical Combat Casualty Care)|medical"
|
||||
"https://archive.org/download/Fm4-25.11FirstAid/Fm4-25.11FirstAid.pdf|FM 4-25.11 First Aid|medical"
|
||||
"https://archive.org/download/FM21-10_2000/FM21-10_2000.pdf|FM 21-10 Field Hygiene and Sanitation (2000)|medical"
|
||||
|
||||
# ========== SURVIVAL ==========
|
||||
"https://trueprepper.com/wp-content/uploads/2022/11/FM-21-76-US-Army-Survival-Manual.pdf|FM 21-76 US Army Survival Manual (1992)|survival"
|
||||
"https://archive.org/download/MManuals/Fm3-05.70Survival.pdf|FM 3-05.70 Survival (2002)|survival"
|
||||
"https://www.globalsecurity.org/military/library/policy/army/fm/21-76-1/fm_21-76-1survival.pdf|FM 21-76-1 Survival Evasion Recovery|survival"
|
||||
|
||||
# ========== MILITARY / DEFENSE ==========
|
||||
"https://armypubs.army.mil/epubs/DR_pubs/DR_a/pdf/web/ARN36119_TC%203-22x9%20FINAL%20WEB.pdf|TC 3-22.9 Rifle and Carbine Marksmanship|defense"
|
||||
"https://armypubs.army.mil/epubs/DR_pubs/DR_a/pdf/web/ARN20697_FM%203-06%20FINAL%20WEB%201.pdf|FM 3-06 Urban Operations|defense"
|
||||
"https://armypubs.army.mil/epubs/DR_pubs/DR_a/pdf/web/atp3_21x8.pdf|ATP 3-21.8 Infantry Platoon and Squad|defense"
|
||||
"https://www.bits.de/NRANEU/others/amd-us-archive/fm5-31%2865%29.pdf|FM 5-31 Boobytraps|defense"
|
||||
|
||||
# ========== NAVIGATION ==========
|
||||
"https://www.globalsecurity.org/military/library/policy/army/fm/3-25-26/fm3-25-26.pdf|FM 3-25.26 Map Reading and Land Navigation|navigation"
|
||||
"https://navlist.net/imgx/Bowditch-2017-v1.pdf|Bowditch American Practical Navigator V1 (2017)|navigation"
|
||||
"https://navlist.net/imgx/Bowditch-2017-v2.pdf|Bowditch American Practical Navigator V2 (2017)|navigation"
|
||||
|
||||
# ========== COMMUNICATIONS ==========
|
||||
"https://www.cisa.gov/sites/default/files/2024-12/NIFOG%202.02_508%20FINAL%20VERSION%2012%2003%202024.pdf|NIFOG 2.02 - National Interop Field Ops Guide|comms"
|
||||
"https://www.qsl.net/w/wb4bxw/books/ARRL_Ham_Radio_License_Manual.pdf|ARRL Ham Radio License Manual (Technician)|comms"
|
||||
"https://www.govinfo.gov/content/pkg/CFR-2024-title47-vol5/pdf/CFR-2024-title47-vol5-part97.pdf|FCC Part 97 Amateur Radio Service Rules (2024)|comms"
|
||||
|
||||
# ========== PREPAREDNESS ==========
|
||||
"https://www.ready.gov/sites/default/files/2021-11/are-you-ready-guide.pdf|FEMA Are You Ready Guide|preparedness"
|
||||
|
||||
# ========== FOOD PRESERVATION ==========
|
||||
"https://archive.org/download/usda-complete-guide-to-home-canning-2015-revision/USDA-Complete-Guide-to-Home-Canning-2015-revision.pdf|USDA Complete Guide to Home Canning 2015|food"
|
||||
|
||||
# ========== TRADES / REPAIR (Audel, vintage public domain) ==========
|
||||
"https://archive.org/download/audel.carpenter.no1.1923/audel.carpenter.no1.1923.pdf|Audels Carpenters and Builders Guide Vol 1 (1923)|repair"
|
||||
|
||||
# ========== ENGINEERING / SHOP ==========
|
||||
"https://archive.org/download/machineryshandbo00indu/machineryshandbo00indu.pdf|Machinery's Handbook 6th Edition (1924)|engineering"
|
||||
)
|
||||
|
||||
ok=0; fail=0; total=${#PDFS[@]}
|
||||
i=0
|
||||
for entry in "${PDFS[@]}"; do
|
||||
i=$((i+1))
|
||||
IFS='|' read -r url title tag <<< "$entry"
|
||||
fname=$(echo "$title" | tr -c 'A-Za-z0-9._-' '_' | head -c 100).pdf
|
||||
out="$TMP/$fname"
|
||||
echo ""
|
||||
echo "==> [$i/$total] [$title]"
|
||||
echo " URL: $url"
|
||||
if wget -q --show-progress -U "Mozilla/5.0" --tries=2 --timeout=60 -O "$out" "$url"; then
|
||||
if [ -s "$out" ] && file "$out" | grep -qi "PDF"; then
|
||||
sz=$(du -h "$out" | cut -f1)
|
||||
curl -s -X POST "$API" -F "file=@$out;type=application/pdf" -F "title=$title" -F "tag=$tag" > /dev/null
|
||||
echo " ✓ uploaded ($sz)"
|
||||
ok=$((ok+1))
|
||||
else
|
||||
echo " ✗ skipped (not a valid PDF)"
|
||||
fail=$((fail+1))
|
||||
fi
|
||||
rm -f "$out"
|
||||
else
|
||||
echo " ✗ download failed"
|
||||
fail=$((fail+1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "================================"
|
||||
echo "OK: $ok Failed: $fail Total: $total"
|
||||
echo "================================"
|
||||
rm -rf "$TMP"
|
||||
Binary file not shown.
@@ -0,0 +1,119 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
import httpx, asyncio, re, html as html_lib
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
app = FastAPI(redirect_slashes=False)
|
||||
|
||||
KIWIX_URL = "http://kiwix:8080"
|
||||
PDFS_URL = "http://pdfs:8000"
|
||||
FREQS_URL = "http://freqs:8000"
|
||||
INV_URL = "http://inventory:8000"
|
||||
|
||||
def text_match(item, q, fields):
|
||||
ql = q.lower()
|
||||
for f in fields:
|
||||
v = item.get(f) or ""
|
||||
if ql in str(v).lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
async def search_kiwix(client, q, limit=30):
|
||||
"""Use OPDS catalog search to find matching ZIM books."""
|
||||
try:
|
||||
r = await client.get(
|
||||
f"{KIWIX_URL}/library/catalog/v2/entries",
|
||||
params={"q": q, "count": limit},
|
||||
timeout=8.0
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
text = r.text
|
||||
results = []
|
||||
for entry in re.findall(r"<entry>(.*?)</entry>", text, re.DOTALL)[:limit]:
|
||||
t = re.search(r"<title[^>]*>(.*?)</title>", entry, re.DOTALL)
|
||||
name = re.search(r"<name>(.*?)</name>", entry, re.DOTALL)
|
||||
s = re.search(r"<summary[^>]*>(.*?)</summary>", entry, re.DOTALL)
|
||||
# Find the content link
|
||||
content = re.search(r"<link[^>]*href=\"(/library/content/[^\"]+)\"", entry)
|
||||
if t and content:
|
||||
results.append({
|
||||
"source": "Library",
|
||||
"title": html_lib.unescape(t.group(1).strip()),
|
||||
"url": content.group(1),
|
||||
"snippet": re.sub(r"<[^>]+>", "", html_lib.unescape(s.group(1)))[:300] if s else "",
|
||||
"context": name.group(1).strip() if name else "",
|
||||
})
|
||||
return results
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def search_pdfs(client, q, limit=20):
|
||||
try:
|
||||
r = await client.get(f"{PDFS_URL}/api/list", timeout=5.0)
|
||||
if r.status_code != 200: return []
|
||||
items = r.json()
|
||||
matches = [i for i in items if text_match(i, q, ["title", "tag", "filename"])][:limit]
|
||||
return [{
|
||||
"source": "PDFs",
|
||||
"title": i["title"],
|
||||
"url": f"/pdfs/view/{i['id']}",
|
||||
"snippet": f"Tag: {i.get('tag') or 'none'}",
|
||||
"context": i.get("tag", ""),
|
||||
} for i in matches]
|
||||
except: return []
|
||||
|
||||
async def search_freqs(client, q, limit=20):
|
||||
try:
|
||||
r = await client.get(f"{FREQS_URL}/api/list", timeout=5.0)
|
||||
if r.status_code != 200: return []
|
||||
items = r.json()
|
||||
matches = [i for i in items if text_match(i, q, ["name","freq","band","mode","tone","tag","description"])][:limit]
|
||||
return [{
|
||||
"source": "Frequencies",
|
||||
"title": f"{i.get('freq','')} - {i.get('name','')}",
|
||||
"url": "/freqs/",
|
||||
"snippet": i.get("description") or f"{i.get('band','')} {i.get('mode','')} {i.get('tone','')}".strip(),
|
||||
"context": i.get("band", ""),
|
||||
} for i in matches]
|
||||
except: return []
|
||||
|
||||
async def search_inventory(client, q, limit=20):
|
||||
try:
|
||||
r = await client.get(f"{INV_URL}/api/list", timeout=5.0)
|
||||
if r.status_code != 200: return []
|
||||
items = r.json()
|
||||
matches = [i for i in items if text_match(i, q, ["name","category","location","notes"])][:limit]
|
||||
return [{
|
||||
"source": "Inventory",
|
||||
"title": i["name"],
|
||||
"url": "/inventory/",
|
||||
"snippet": f"Qty: {i.get('quantity',0)} {i.get('unit','')} @ {i.get('location','?')} | {i.get('notes','')}".strip(),
|
||||
"context": i.get("category", ""),
|
||||
} for i in matches]
|
||||
except: return []
|
||||
|
||||
@app.get("/api/search")
|
||||
async def search(q: str = ""):
|
||||
q = q.strip()
|
||||
if not q:
|
||||
return JSONResponse({"results": {}, "total": 0})
|
||||
async with httpx.AsyncClient() as client:
|
||||
zims, pdfs, freqs, inv = await asyncio.gather(
|
||||
search_kiwix(client, q),
|
||||
search_pdfs(client, q),
|
||||
search_freqs(client, q),
|
||||
search_inventory(client, q),
|
||||
)
|
||||
grouped = {}
|
||||
if zims: grouped["Library"] = zims
|
||||
if pdfs: grouped["PDFs"] = pdfs
|
||||
if freqs: grouped["Frequencies"] = freqs
|
||||
if inv: grouped["Inventory"] = inv
|
||||
total = sum(len(v) for v in grouped.values())
|
||||
return {"results": grouped, "total": total}
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def root():
|
||||
with open("/static/index.html") as f:
|
||||
return f.read()
|
||||
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Search - The Dark Elite</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
:root { --bg:#060708; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; }
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;min-height:100vh}
|
||||
.container{max-width:1100px;margin:0 auto;padding:2rem}
|
||||
header{display:flex;justify-content:space-between;align-items:center;padding-bottom:1.5rem;border-bottom:1px solid var(--border);margin-bottom:2rem}
|
||||
header h1{margin:0;font-size:1.5rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent);text-shadow:0 0 12px rgba(34,211,238,0.4)}
|
||||
header a{color:var(--muted);text-decoration:none;font-size:0.85rem}
|
||||
header a:hover{color:var(--accent)}
|
||||
.search-box{display:flex;gap:0.5rem;margin-bottom:2rem}
|
||||
.search-box input{flex:1;background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.85rem 1rem;border-radius:6px;font-size:1rem;font-family:ui-monospace,monospace}
|
||||
.search-box input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 18px rgba(34,211,238,0.15)}
|
||||
.search-box button{background:rgba(34,211,238,0.15);border:1px solid var(--accent);color:var(--accent);padding:0 1.5rem;border-radius:6px;font-size:0.85rem;cursor:pointer;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace}
|
||||
.search-box button:hover{background:rgba(34,211,238,0.25)}
|
||||
.summary{font-size:0.8rem;color:var(--muted);font-family:ui-monospace,monospace;margin-bottom:1rem}
|
||||
.section{margin-bottom:2rem}
|
||||
.section-title{font-size:0.75rem;letter-spacing:0.25em;text-transform:uppercase;color:var(--accent);border-left:3px solid var(--accent);padding-left:0.75rem;margin-bottom:1rem;display:flex;justify-content:space-between;align-items:center}
|
||||
.section-title .count{color:var(--muted);font-size:0.7rem}
|
||||
.result{display:block;background:linear-gradient(135deg,rgba(18,20,24,0.85),rgba(10,11,13,0.95));border:1px solid var(--border);border-radius:6px;padding:0.85rem 1rem;margin-bottom:0.5rem;text-decoration:none;color:inherit;transition:all 0.15s}
|
||||
.result:hover{border-color:rgba(34,211,238,0.5);transform:translateX(3px)}
|
||||
.result .title{font-size:0.95rem;color:var(--text);font-weight:500}
|
||||
.result .snippet{font-size:0.78rem;color:var(--muted);margin-top:0.25rem;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.result .context{display:inline-block;font-size:0.65rem;color:rgb(165,235,247);background:rgba(34,211,238,0.1);border:1px solid rgba(34,211,238,0.25);padding:0.1rem 0.4rem;border-radius:3px;margin-top:0.4rem;font-family:ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.1em}
|
||||
.empty{color:var(--muted);text-align:center;padding:2rem;font-style:italic}
|
||||
.loading{color:var(--accent);text-align:center;padding:2rem;font-family:ui-monospace,monospace;letter-spacing:0.2em;text-transform:uppercase;font-size:0.85rem}
|
||||
.hint{color:var(--muted);font-size:0.85rem;text-align:center;padding:2rem;line-height:1.7}
|
||||
.hint code{background:rgba(34,211,238,0.08);border:1px solid rgba(34,211,238,0.2);color:rgb(165,235,247);padding:0.1rem 0.4rem;border-radius:3px;font-family:ui-monospace,monospace}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Search</h1>
|
||||
<a href="/" style="color:#9ca3af;text-decoration:none;font-size:0.85rem">← Hub</a></header>
|
||||
|
||||
<form class="search-box" id="searchForm">
|
||||
<input type="text" id="q" placeholder="Search across Library, PDFs, Frequencies, Inventory..." autofocus>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<div id="summary" class="summary"></div>
|
||||
<div id="results"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const initial = params.get("q") || "";
|
||||
if (initial) document.getElementById("q").value = initial;
|
||||
|
||||
async function doSearch(q){
|
||||
if(!q){
|
||||
document.getElementById("results").innerHTML = '<div class="hint">Type a query above. Searches across<br>Kiwix ZIMs, PDFs, Frequencies, and Inventory.<br><br>Example: <code>carburetor</code>, <code>FM 21-76</code>, <code>NOAA</code></div>';
|
||||
document.getElementById("summary").textContent = "";
|
||||
return;
|
||||
}
|
||||
document.getElementById("results").innerHTML = '<div class="loading">Searching...</div>';
|
||||
document.getElementById("summary").textContent = "";
|
||||
history.replaceState({}, "", "?q=" + encodeURIComponent(q));
|
||||
|
||||
try {
|
||||
const r = await fetch("/search/api/search?q=" + encodeURIComponent(q));
|
||||
const data = await r.json();
|
||||
render(q, data);
|
||||
} catch(e){
|
||||
document.getElementById("results").innerHTML = '<div class="empty">Search failed: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function render(q, data){
|
||||
const total = data.total || 0;
|
||||
document.getElementById("summary").textContent = total + " result" + (total===1?"":"s") + " for \"" + q + "\"";
|
||||
if(!total){
|
||||
document.getElementById("results").innerHTML = '<div class="empty">No matches.</div>';
|
||||
return;
|
||||
}
|
||||
const out = [];
|
||||
for (const [source, items] of Object.entries(data.results)){
|
||||
out.push('<div class="section"><div class="section-title">' + source + '<span class="count">' + items.length + '</span></div>');
|
||||
out.push(items.map(it =>
|
||||
'<a class="result" href="' + it.url + '">' +
|
||||
'<div class="title">' + escapeHtml(it.title) + '</div>' +
|
||||
(it.snippet ? '<div class="snippet">' + escapeHtml(it.snippet) + '</div>' : '') +
|
||||
(it.context ? '<div class="context">' + escapeHtml(it.context) + '</div>' : '') +
|
||||
'</a>'
|
||||
).join(""));
|
||||
out.push('</div>');
|
||||
}
|
||||
document.getElementById("results").innerHTML = out.join("");
|
||||
}
|
||||
|
||||
function escapeHtml(s){
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
document.getElementById("searchForm").addEventListener("submit", e => {
|
||||
e.preventDefault();
|
||||
doSearch(document.getElementById("q").value.trim());
|
||||
});
|
||||
|
||||
doSearch(initial);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Weather - The Dark Elite</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
|
||||
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
||||
<script src="https://unpkg.com/pmtiles@3.2.1/dist/pmtiles.js"></script>
|
||||
<script src="https://unpkg.com/protomaps-themes-base@4.3.0/dist/protomaps-themes-base.js"></script>
|
||||
<style>
|
||||
:root { --bg:#060708; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font-family:system-ui,sans-serif; min-height:100vh; }
|
||||
.container { max-width:1200px; margin:0 auto; padding:2rem; }
|
||||
header { display:flex; justify-content:space-between; align-items:center; padding-bottom:1.5rem; border-bottom:1px solid var(--border); margin-bottom:2rem; }
|
||||
header h1 { margin:0; font-size:1.5rem; letter-spacing:0.2em; text-transform:uppercase; color:var(--accent); text-shadow:0 0 12px rgba(34,211,238,0.4); }
|
||||
header a { color:var(--muted); text-decoration:none; font-size:0.85rem; }
|
||||
header a:hover { color:var(--accent); }
|
||||
.status { font-size:0.75rem; color:var(--muted); font-family:ui-monospace,monospace; }
|
||||
.status.live { color:#22c55e; }
|
||||
.status.cached { color:#f59e0b; }
|
||||
.current { display:grid; grid-template-columns:auto 1fr; gap:2rem; align-items:center; padding:2rem; background:linear-gradient(135deg,rgba(18,20,24,0.9),rgba(10,11,13,0.95)); border:1px solid var(--border); border-radius:8px; margin-bottom:2rem; }
|
||||
.current .temp { font-size:5rem; font-weight:200; color:var(--accent); text-shadow:0 0 20px rgba(34,211,238,0.3); line-height:1; }
|
||||
.current .meta { display:grid; grid-template-columns:repeat(2,1fr); gap:0.75rem; }
|
||||
.current .meta strong { color:var(--muted); font-weight:400; text-transform:uppercase; font-size:0.7rem; letter-spacing:0.15em; display:block; margin-bottom:0.2rem; }
|
||||
.section-title { font-size:0.75rem; letter-spacing:0.25em; text-transform:uppercase; color:var(--accent); border-left:3px solid var(--accent); padding-left:0.75rem; margin:2rem 0 1rem 0; display:flex; justify-content:space-between; align-items:center; }
|
||||
.section-title button { background:rgba(18,20,24,0.9); border:1px solid var(--border); color:var(--text); padding:0.3rem 0.8rem; font-size:0.7rem; cursor:pointer; border-radius:3px; letter-spacing:0.1em; }
|
||||
.section-title button:hover { border-color:var(--accent); color:var(--accent); }
|
||||
#radar-wrap { position:relative; height:500px; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
|
||||
#radar-map { width:100%; height:100%; }
|
||||
#radar-time { position:absolute; bottom:10px; left:10px; background:rgba(6,7,8,0.85); padding:0.4rem 0.8rem; border:1px solid var(--border); border-radius:4px; font-family:ui-monospace,monospace; font-size:0.8rem; color:var(--accent); z-index:5; }
|
||||
.forecast { display:grid; grid-template-columns:repeat(7,1fr); gap:0.75rem; }
|
||||
.day { background:linear-gradient(135deg,rgba(18,20,24,0.85),rgba(10,11,13,0.9)); border:1px solid var(--border); border-radius:6px; padding:1rem; text-align:center; transition:border-color 0.15s; }
|
||||
.day:hover { border-color:rgba(34,211,238,0.4); }
|
||||
.day .dow { font-size:0.75rem; color:var(--muted); text-transform:uppercase; letter-spacing:0.15em; margin-bottom:0.5rem; }
|
||||
.day .hi { font-size:1.4rem; color:var(--text); }
|
||||
.day .lo { font-size:0.85rem; color:var(--muted); }
|
||||
.day .precip { font-size:0.7rem; color:#60a5fa; margin-top:0.4rem; }
|
||||
.alerts { display:flex; flex-direction:column; gap:0.5rem; }
|
||||
.alert { background:rgba(220,38,38,0.1); border:1px solid rgba(220,38,38,0.3); border-left:3px solid #dc2626; border-radius:4px; padding:0.75rem 1rem; font-size:0.85rem; }
|
||||
.alert strong { color:#fca5a5; text-transform:uppercase; font-size:0.7rem; letter-spacing:0.15em; }
|
||||
.empty { color:var(--muted); font-style:italic; font-size:0.85rem; }
|
||||
@media (max-width:768px) { .forecast { grid-template-columns:repeat(3,1fr); } .current { grid-template-columns:1fr; } .current .temp { font-size:3.5rem; } #radar-wrap { height:350px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div><h1>Weather Intel</h1><div id="loc" class="status">Loading...</div></div>
|
||||
<div style="text-align:right"><a href="/">← Hub</a><br><span id="status" class="status">Initializing</span></div>
|
||||
</header>
|
||||
<div class="current"><div class="temp" id="temp">--</div><div class="meta">
|
||||
<div><strong>Conditions</strong><span id="cond">--</span></div>
|
||||
<div><strong>Feels Like</strong><span id="feels">--</span></div>
|
||||
<div><strong>Humidity</strong><span id="hum">--</span></div>
|
||||
<div><strong>Wind</strong><span id="wind">--</span></div>
|
||||
<div><strong>Pressure</strong><span id="pres">--</span></div>
|
||||
<div><strong>Visibility</strong><span id="vis">--</span></div>
|
||||
</div></div>
|
||||
<div class="section-title">Live Radar<button id="play-btn">Pause</button></div>
|
||||
<div id="radar-wrap"><div id="radar-map"></div><div id="radar-time">--</div></div>
|
||||
<div class="section-title">Active Alerts</div><div id="alerts" class="alerts"><div class="empty">No active alerts</div></div>
|
||||
<div class="section-title">7-Day Forecast</div><div id="forecast" class="forecast"></div>
|
||||
</div>
|
||||
<script>
|
||||
const LAT=32.1268, LON=-96.9469, LOC="Milford, TX";
|
||||
const WMO={0:"Clear",1:"Mostly Clear",2:"Partly Cloudy",3:"Overcast",45:"Foggy",48:"Foggy",51:"Light Drizzle",53:"Drizzle",55:"Heavy Drizzle",61:"Light Rain",63:"Rain",65:"Heavy Rain",71:"Light Snow",73:"Snow",75:"Heavy Snow",80:"Showers",81:"Heavy Showers",82:"Violent Showers",95:"Thunderstorm",96:"T-storm w/ Hail",99:"T-storm w/ Hail"};
|
||||
const DOW=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
|
||||
document.getElementById("loc").textContent = LOC + " // " + LAT.toFixed(4) + "," + LON.toFixed(4);
|
||||
function setStatus(t,cls){const e=document.getElementById("status");e.textContent=t;e.className="status "+(cls||"");}
|
||||
|
||||
async function fetchWeather(){
|
||||
try {
|
||||
const url="https://api.open-meteo.com/v1/forecast?latitude="+LAT+"&longitude="+LON+"¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,pressure_msl,wind_speed_10m,wind_direction_10m,visibility&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch&timezone=America/Chicago&forecast_days=7";
|
||||
const r=await fetch(url); if(!r.ok) throw new Error(r.status);
|
||||
const d=await r.json();
|
||||
localStorage.setItem("wx_cache",JSON.stringify({data:d,ts:Date.now()}));
|
||||
render(d); setStatus("Live // "+new Date().toLocaleTimeString(),"live");
|
||||
} catch(e) {
|
||||
const c=localStorage.getItem("wx_cache");
|
||||
if(c){const o=JSON.parse(c);render(o.data);setStatus("Cached // "+new Date(o.ts).toLocaleString(),"cached");}
|
||||
else setStatus("Offline - no cache","cached");
|
||||
}
|
||||
}
|
||||
|
||||
function render(d){
|
||||
const c=d.current;
|
||||
document.getElementById("temp").textContent=Math.round(c.temperature_2m)+"\u00B0";
|
||||
document.getElementById("cond").textContent=WMO[c.weather_code]||"Unknown";
|
||||
document.getElementById("feels").textContent=Math.round(c.apparent_temperature)+"\u00B0F";
|
||||
document.getElementById("hum").textContent=c.relative_humidity_2m+"%";
|
||||
const dir=["N","NE","E","SE","S","SW","W","NW"][Math.round(c.wind_direction_10m/45)%8];
|
||||
document.getElementById("wind").textContent=Math.round(c.wind_speed_10m)+" mph "+dir;
|
||||
document.getElementById("pres").textContent=c.pressure_msl.toFixed(1)+" hPa";
|
||||
document.getElementById("vis").textContent=c.visibility?Math.round(c.visibility/1609)+" mi":"--";
|
||||
const fc=document.getElementById("forecast"); fc.innerHTML="";
|
||||
for(let i=0;i<7;i++){
|
||||
const dt=new Date(d.daily.time[i]+"T00:00");
|
||||
fc.innerHTML+='<div class="day"><div class="dow">'+DOW[dt.getDay()]+'</div><div class="hi">'+Math.round(d.daily.temperature_2m_max[i])+'\u00B0</div><div class="lo">'+Math.round(d.daily.temperature_2m_min[i])+'\u00B0</div><div class="precip">'+(d.daily.precipitation_probability_max[i]||0)+'% precip</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAlerts(){
|
||||
try {
|
||||
const r=await fetch("https://api.weather.gov/alerts/active?point="+LAT+","+LON);
|
||||
if(!r.ok) throw new Error(r.status);
|
||||
const d=await r.json();
|
||||
const el=document.getElementById("alerts");
|
||||
if(!d.features||!d.features.length){el.innerHTML='<div class="empty">No active alerts</div>';return;}
|
||||
el.innerHTML=d.features.slice(0,5).map(f=>'<div class="alert"><strong>'+f.properties.event+'</strong><br>'+(f.properties.headline||"")+'</div>').join("");
|
||||
} catch(e){}
|
||||
}
|
||||
|
||||
const protocol=new pmtiles.Protocol();
|
||||
maplibregl.addProtocol("pmtiles",protocol.tile);
|
||||
let radarMap, radarFrames=[], radarIdx=0, radarTimer=null, radarPlaying=true;
|
||||
|
||||
function initRadar(){
|
||||
radarMap=new maplibregl.Map({
|
||||
container:"radar-map",
|
||||
style:{version:8,
|
||||
glyphs:"https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf",
|
||||
sprite:"https://protomaps.github.io/basemaps-assets/sprites/v4/dark",
|
||||
sources:{protomaps:{type:"vector",url:"pmtiles:///maps/base.pmtiles"}},
|
||||
layers:protomaps_themes_base.default("protomaps","dark")
|
||||
},
|
||||
center:[LON,LAT], zoom:6
|
||||
});
|
||||
radarMap.on("load",()=>{
|
||||
new maplibregl.Marker({color:"#22d3ee"}).setLngLat([LON,LAT]).addTo(radarMap);
|
||||
loadRadarFrames();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRadarFrames(){
|
||||
try {
|
||||
const r=await fetch("https://api.rainviewer.com/public/weather-maps.json");
|
||||
const d=await r.json();
|
||||
const newFrames=[...d.radar.past, ...d.radar.nowcast].map(f=>({time:f.time,path:d.host+f.path}));
|
||||
radarFrames.forEach((_,i)=>{
|
||||
if(radarMap.getLayer("radar-"+i)) radarMap.removeLayer("radar-"+i);
|
||||
if(radarMap.getSource("radar-"+i)) radarMap.removeSource("radar-"+i);
|
||||
});
|
||||
radarFrames=newFrames;
|
||||
radarFrames.forEach((f,i)=>{
|
||||
radarMap.addSource("radar-"+i,{type:"raster",tiles:[f.path+"/256/{z}/{x}/{y}/2/1_1.png"],tileSize:256});
|
||||
radarMap.addLayer({id:"radar-"+i,type:"raster",source:"radar-"+i,paint:{"raster-opacity":0}});
|
||||
});
|
||||
radarIdx=0; showFrame(0);
|
||||
if(radarPlaying && !radarTimer) startLoop();
|
||||
} catch(e){ document.getElementById("radar-time").textContent="Radar offline"; }
|
||||
}
|
||||
|
||||
function showFrame(i){
|
||||
radarFrames.forEach((f,j)=>{ if(radarMap.getLayer("radar-"+j)) radarMap.setPaintProperty("radar-"+j,"raster-opacity",j===i?0.75:0); });
|
||||
const t=new Date(radarFrames[i].time*1000);
|
||||
const isFuture=radarFrames[i].time*1000>Date.now();
|
||||
document.getElementById("radar-time").textContent=(isFuture?"FORECAST ":"")+t.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});
|
||||
}
|
||||
|
||||
function startLoop(){
|
||||
radarTimer=setInterval(()=>{radarIdx=(radarIdx+1)%radarFrames.length;showFrame(radarIdx);},500);
|
||||
}
|
||||
|
||||
document.getElementById("play-btn").addEventListener("click",e=>{
|
||||
radarPlaying=!radarPlaying;
|
||||
if(radarPlaying){startLoop();e.target.textContent="Pause";}
|
||||
else{clearInterval(radarTimer);radarTimer=null;e.target.textContent="Play";}
|
||||
});
|
||||
|
||||
initRadar(); fetchWeather(); fetchAlerts();
|
||||
setInterval(fetchWeather,600000); setInterval(fetchAlerts,300000); setInterval(loadRadarFrames,600000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -0,0 +1,46 @@
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException
|
||||
import os, subprocess, re
|
||||
from datetime import datetime
|
||||
|
||||
ZIM_DIR = "/zims"
|
||||
app = FastAPI()
|
||||
|
||||
def safe(name):
|
||||
return re.sub(r'[^\w\-. ]', '_', name)[:200]
|
||||
|
||||
@app.post("/api/upload")
|
||||
async def upload(file: UploadFile = File(...)):
|
||||
if not file.filename.lower().endswith(".zim"):
|
||||
raise HTTPException(400, "ZIMs only")
|
||||
fname = safe(file.filename)
|
||||
path = os.path.join(ZIM_DIR, fname)
|
||||
with open(path, "wb") as f:
|
||||
while True:
|
||||
chunk = await file.read(8 * 1024 * 1024)
|
||||
if not chunk: break
|
||||
f.write(chunk)
|
||||
return {"ok": True, "filename": fname, "size": os.path.getsize(path)}
|
||||
|
||||
@app.delete("/api/delete/{filename}")
|
||||
def delete(filename: str):
|
||||
fn = safe(filename)
|
||||
path = os.path.join(ZIM_DIR, fn)
|
||||
if not os.path.exists(path):
|
||||
# Try to find a file that starts with the base name (handles date-stripped catalog names)
|
||||
base = fn[:-4] if fn.endswith('.zim') else fn
|
||||
matches = [f for f in os.listdir(ZIM_DIR) if f.startswith(base) and f.endswith('.zim')]
|
||||
if not matches:
|
||||
raise HTTPException(404, f"No file matching {fn} or {base}*.zim")
|
||||
if len(matches) > 1:
|
||||
raise HTTPException(409, f"Multiple matches: {matches}")
|
||||
path = os.path.join(ZIM_DIR, matches[0])
|
||||
os.remove(path)
|
||||
return {"ok": True, "deleted": os.path.basename(path)}
|
||||
|
||||
@app.post("/api/reload")
|
||||
def reload_kiwix():
|
||||
try:
|
||||
subprocess.run(["docker", "restart", "kiwix"], check=True, capture_output=True, timeout=60)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
Reference in New Issue
Block a user