Initial commit

This commit is contained in:
2026-06-30 18:30:24 +00:00
commit 67e0a90b15
54 changed files with 6184 additions and 0 deletions
+13
View File
@@ -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
+51
View File
@@ -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
View File
@@ -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.
Executable
+104
View File
@@ -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}"
+158
View File
@@ -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
View File
@@ -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.
+79
View File
@@ -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()
+68
View File
@@ -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>
+107
View File
@@ -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.
+62
View File
@@ -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()
+409
View File
@@ -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"
}
}
+201
View File
@@ -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,"&quot;")+'">';
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>
+641
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>
+87
View File
@@ -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
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+2
View File
@@ -0,0 +1,2 @@
---
# sample kubernetes config
+5
View File
@@ -0,0 +1,5 @@
---
# pve:
# url: https://proxmox.host.or.ip:8006
# token: username@pam!Token ID
# secret: secret
+43
View File
@@ -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
+15
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
- resources:
cpu: true
memory: true
disk: /
- search:
provider: custom
url: /search/?q=
target: _self
Binary file not shown.
+80
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
{}
+226
View File
@@ -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 &lt; 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,"&quot;")+'">';
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>
View File
+242
View File
@@ -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||'&nbsp;')+'</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,"&quot;")+'" 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>
+120
View File
@@ -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.
+58
View File
@@ -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
+478
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 + ' &middot; 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.
+95
View File
@@ -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")
+205
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
{}
+71
View File
@@ -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()
+109
View File
@@ -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"
+48
View File
@@ -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"
+43
View File
@@ -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"
+87
View File
@@ -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.
+119
View File
@@ -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()
+109
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
document.getElementById("searchForm").addEventListener("submit", e => {
e.preventDefault();
doSearch(document.getElementById("q").value.trim());
});
doSearch(initial);
</script>
</body>
</html>
+176
View File
@@ -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+"&current=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.
+46
View File
@@ -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))