Compare commits
39 Commits
b7866000b4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2969648aa1 | |||
| e727ed35a9 | |||
| 999dda22df | |||
| 4c474c0916 | |||
| 585b7f2639 | |||
| f02ac40b91 | |||
| abe61d1156 | |||
| 79b6d4857d | |||
| 598bd83887 | |||
| 6a4eee9a15 | |||
| 4b1ea2da11 | |||
| 0d31af526c | |||
| 3d8aef5f22 | |||
| ac6ca6d1b3 | |||
| 46cd4f77c9 | |||
| 1b06a29e53 | |||
| fd519a64b0 | |||
| a2c14a4a39 | |||
| ab423ef1a7 | |||
| e9ee8caccf | |||
| 68dccf958e | |||
| 0dcfe060e5 | |||
| 86f6334737 | |||
| 2431c6b6ae | |||
| 5d3bcb679d | |||
| 23779690d6 | |||
| 96838f9f67 | |||
| 05f20355c8 | |||
| 165e77b9b8 | |||
| d3b05f981f | |||
| b73b8fab65 | |||
| 19b02f91e9 | |||
| dcdbedc26e | |||
| 508328b282 | |||
| b3bc19aa44 | |||
| fa329c7e3c | |||
| cb5bf7ff94 | |||
| 312db58775 | |||
| e9207b4315 |
@@ -3,3 +3,4 @@ __pycache__/
|
|||||||
*.pyo
|
*.pyo
|
||||||
.env
|
.env
|
||||||
credentials.md
|
credentials.md
|
||||||
|
iso-output/*.iso
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"claudeCodeChat.permissions.yoloMode": true
|
||||||
|
}
|
||||||
+428
-273
File diff suppressed because it is too large
Load Diff
+107
-104
@@ -1,119 +1,122 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Build SeniorNet Kiosk ISO
|
# SeniorNet Kiosk ISO Builder
|
||||||
# Run on workstation: ./build-iso.sh
|
# Takes the official Debian Trixie netinstall ISO and repacks it with:
|
||||||
# Requires: debian-live-build, sudo
|
# - preseed.cfg embedded for silent auto-install
|
||||||
|
# - GRUB + isolinux configured for auto-boot (UEFI + BIOS)
|
||||||
echo "==> SeniorNet Kiosk ISO Builder"
|
#
|
||||||
echo
|
# The kiosk app itself is NOT bundled — it's downloaded from Gitea at
|
||||||
|
# first-boot time. Rebuild the ISO only when changing Debian base or preseed.
|
||||||
|
#
|
||||||
|
# Run: sudo ./build-iso.sh
|
||||||
|
# Output: iso-output/seniornet-kiosk.iso
|
||||||
|
|
||||||
if [ "$EUID" -ne 0 ]; then
|
if [ "$EUID" -ne 0 ]; then
|
||||||
echo "ERROR: Run as root (sudo ./build-iso.sh)"
|
echo "ERROR: Run as root: sudo ./build-iso.sh"; exit 1
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check dependencies
|
|
||||||
if ! command -v lb &>/dev/null; then
|
|
||||||
echo "==> Installing debian-live-build..."
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y debian-live-build
|
|
||||||
fi
|
|
||||||
|
|
||||||
BUILD_DIR="/tmp/kiosk-build-$$"
|
|
||||||
OUTPUT_DIR="$(pwd)/iso-output"
|
|
||||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
OUTPUT_DIR="$REPO_DIR/iso-output"
|
||||||
|
WORK_DIR="$(mktemp -d /tmp/kiosk-iso-XXXX)"
|
||||||
|
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||||
|
|
||||||
mkdir -p "$BUILD_DIR" "$OUTPUT_DIR"
|
DEBIAN_ISO_URL="https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-13.5.0-amd64-netinst.iso"
|
||||||
cd "$BUILD_DIR"
|
ISO_CACHE="$OUTPUT_DIR/debian-netinst.iso"
|
||||||
|
OUTPUT_ISO="$OUTPUT_DIR/seniornet-kiosk.iso"
|
||||||
|
|
||||||
echo "==> Initializing live-build..."
|
mkdir -p "$OUTPUT_DIR"
|
||||||
lb config \
|
|
||||||
--distribution trixie \
|
|
||||||
--architectures amd64 \
|
|
||||||
--image-type iso-hybrid \
|
|
||||||
--bootappend-live "boot=live components autologin username=root quiet splash" \
|
|
||||||
--bootappend-install "preseed/file=/preseed.cfg auto=true priority=critical" \
|
|
||||||
--linux-packages "linux-image-amd64" \
|
|
||||||
--parent-mirror-bootstrap "https://deb.debian.org/debian" \
|
|
||||||
--parent-mirror-binary "https://deb.debian.org/debian" \
|
|
||||||
--parent-mirror-binary-security "https://security.debian.org/debian-security" \
|
|
||||||
--parent-distribution-binary-security trixie-security
|
|
||||||
|
|
||||||
echo "==> Adding packages..."
|
# ── Dependencies ───────────────────────────────────────────────────
|
||||||
mkdir -p config/package-lists
|
echo "==> Checking dependencies..."
|
||||||
cat > config/package-lists/kiosk.list.chroot << 'PKGLIST'
|
MISSING=""
|
||||||
# Base system
|
for pkg in xorriso isolinux curl; do
|
||||||
openssh-server
|
dpkg -l "$pkg" &>/dev/null || MISSING="$MISSING $pkg"
|
||||||
build-essential
|
done
|
||||||
git
|
if [ -n "$MISSING" ]; then
|
||||||
curl
|
apt-get install -y $MISSING
|
||||||
wget
|
|
||||||
sudo
|
|
||||||
|
|
||||||
# Display and window manager
|
|
||||||
xserver-xorg
|
|
||||||
xserver-xorg-video-dummy
|
|
||||||
xinit
|
|
||||||
openbox
|
|
||||||
xvfb
|
|
||||||
xdotool
|
|
||||||
xprop
|
|
||||||
|
|
||||||
# Chromium browser
|
|
||||||
chromium
|
|
||||||
|
|
||||||
# Python and kiosk app
|
|
||||||
python3
|
|
||||||
python3-pip
|
|
||||||
sqlite3
|
|
||||||
PKGLIST
|
|
||||||
|
|
||||||
echo "==> Adding preseed file..."
|
|
||||||
mkdir -p config/includes.installer
|
|
||||||
cp "$REPO_DIR/preseed.cfg" config/includes.installer/
|
|
||||||
|
|
||||||
echo "==> Adding hook script..."
|
|
||||||
mkdir -p config/hooks/normal
|
|
||||||
cat > config/hooks/normal/9999-kiosk-postinstall.chroot << 'HOOK'
|
|
||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "==> Running kiosk post-install hook..."
|
|
||||||
|
|
||||||
# Install Python packages
|
|
||||||
pip3 install --break-system-packages PyQt5 websocket-client
|
|
||||||
|
|
||||||
# Clone repo
|
|
||||||
if [ ! -d /home/jgitta/kiosk-browser ]; then
|
|
||||||
git clone https://gitea.jgitta.com/jgitta/senior-kiosk.git /home/jgitta/kiosk-browser
|
|
||||||
cd /home/jgitta/kiosk-browser
|
|
||||||
bash install-kiosk.sh
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Kiosk post-install complete"
|
# ── Download Debian netinstall ISO (cached) ────────────────────────
|
||||||
HOOK
|
if [ -f "$ISO_CACHE" ]; then
|
||||||
chmod +x config/hooks/normal/9999-kiosk-postinstall.chroot
|
echo "==> Using cached Debian ISO: $ISO_CACHE"
|
||||||
|
|
||||||
echo "==> Building ISO (this may take 5-10 minutes)..."
|
|
||||||
lb build 2>&1 | tail -20
|
|
||||||
|
|
||||||
ISO_FILE="live-image-amd64.hybrid.iso"
|
|
||||||
if [ -f "$ISO_FILE" ]; then
|
|
||||||
cp "$ISO_FILE" "$OUTPUT_DIR/seniornet-kiosk.iso"
|
|
||||||
echo
|
|
||||||
echo "==> ✓ ISO build complete!"
|
|
||||||
echo " Output: $OUTPUT_DIR/seniornet-kiosk.iso"
|
|
||||||
echo
|
|
||||||
echo "Next steps:"
|
|
||||||
echo " 1. Write to USB:"
|
|
||||||
echo " sudo dd if=$OUTPUT_DIR/seniornet-kiosk.iso of=/dev/sdX bs=4M status=progress && sync"
|
|
||||||
echo
|
|
||||||
echo " 2. Boot from USB and wait ~10 minutes for silent installation"
|
|
||||||
echo
|
|
||||||
echo " 3. Machine will reboot into kiosk mode automatically"
|
|
||||||
echo
|
|
||||||
else
|
else
|
||||||
echo "ERROR: ISO build failed"
|
echo "==> Downloading Debian 13 Trixie stable netinstall (~700 MB)..."
|
||||||
exit 1
|
curl -L --progress-bar "$DEBIAN_ISO_URL" -o "$ISO_CACHE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Extract ISO ────────────────────────────────────────────────────
|
||||||
|
echo "==> Extracting ISO..."
|
||||||
|
mkdir -p "$WORK_DIR/iso"
|
||||||
|
xorriso -osirrox on -indev "$ISO_CACHE" -extract / "$WORK_DIR/iso" 2>/dev/null
|
||||||
|
chmod -R u+w "$WORK_DIR/iso"
|
||||||
|
|
||||||
|
# ── Add preseed to ISO root ────────────────────────────────────────
|
||||||
|
# Accessible during install as /cdrom/preseed.cfg
|
||||||
|
cp "$REPO_DIR/preseed.cfg" "$WORK_DIR/iso/preseed.cfg"
|
||||||
|
|
||||||
|
# ── Auto-boot: GRUB (UEFI) ────────────────────────────────────────
|
||||||
|
echo "==> Configuring auto-boot..."
|
||||||
|
cat > "$WORK_DIR/iso/boot/grub/grub.cfg" << 'GRUBEOF'
|
||||||
|
set default=0
|
||||||
|
set timeout=5
|
||||||
|
|
||||||
|
menuentry "SeniorNet Kiosk — Automatic Install" {
|
||||||
|
linux /install.amd/vmlinuz preseed/file=/cdrom/preseed.cfg auto=true priority=critical quiet ---
|
||||||
|
initrd /install.amd/initrd.gz
|
||||||
|
}
|
||||||
|
|
||||||
|
menuentry "SeniorNet Kiosk — Install (verbose)" {
|
||||||
|
linux /install.amd/vmlinuz preseed/file=/cdrom/preseed.cfg auto=true priority=critical ---
|
||||||
|
initrd /install.amd/initrd.gz
|
||||||
|
}
|
||||||
|
GRUBEOF
|
||||||
|
|
||||||
|
# ── Auto-boot: isolinux (BIOS legacy) ─────────────────────────────
|
||||||
|
if [ -f "$WORK_DIR/iso/isolinux/isolinux.cfg" ]; then
|
||||||
|
cat > "$WORK_DIR/iso/isolinux/isolinux.cfg" << 'ISOEOF'
|
||||||
|
default install
|
||||||
|
timeout 50
|
||||||
|
|
||||||
|
label install
|
||||||
|
menu label SeniorNet Kiosk - Automatic Install
|
||||||
|
kernel /install.amd/vmlinuz
|
||||||
|
append initrd=/install.amd/initrd.gz preseed/file=/cdrom/preseed.cfg auto=true priority=critical quiet ---
|
||||||
|
|
||||||
|
label verbose
|
||||||
|
menu label SeniorNet Kiosk - Install (verbose)
|
||||||
|
kernel /install.amd/vmlinuz
|
||||||
|
append initrd=/install.amd/initrd.gz preseed/file=/cdrom/preseed.cfg auto=true priority=critical ---
|
||||||
|
ISOEOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Repack ISO (UEFI + BIOS hybrid) ───────────────────────────────
|
||||||
|
echo "==> Building final ISO..."
|
||||||
|
cd "$WORK_DIR/iso"
|
||||||
|
|
||||||
|
xorriso -as mkisofs \
|
||||||
|
-r -J -joliet-long \
|
||||||
|
-V "SeniorNet Kiosk" \
|
||||||
|
-b isolinux/isolinux.bin \
|
||||||
|
-c isolinux/boot.cat \
|
||||||
|
-no-emul-boot -boot-load-size 4 -boot-info-table \
|
||||||
|
-eltorito-alt-boot \
|
||||||
|
-e boot/grub/efi.img \
|
||||||
|
-no-emul-boot \
|
||||||
|
-isohybrid-gpt-basdat \
|
||||||
|
-o "$OUTPUT_ISO" \
|
||||||
|
. 2>&1 | tail -5
|
||||||
|
|
||||||
|
# Make the ISO bootable from USB on both BIOS and UEFI machines
|
||||||
|
isohybrid --uefi "$OUTPUT_ISO"
|
||||||
|
|
||||||
|
echo
|
||||||
|
SIZE=$(du -sh "$OUTPUT_ISO" | cut -f1)
|
||||||
|
echo "==> ✓ ISO complete: $OUTPUT_ISO ($SIZE)"
|
||||||
|
echo
|
||||||
|
echo "Write to USB:"
|
||||||
|
echo " sudo dd if=\"$OUTPUT_ISO\" of=/dev/sdX bs=4M status=progress && sync"
|
||||||
|
echo
|
||||||
|
echo "Boot from USB — Debian installs silently (~10 min) then reboots."
|
||||||
|
echo "On first reboot: setup wizard prompts for username and senior's name."
|
||||||
|
echo "Second reboot: kiosk is running."
|
||||||
|
|||||||
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
"urls": {
|
"urls": {
|
||||||
"email": "https://mail.jgitta.com/roundcube/",
|
"email": "https://mail.jgitta.com/roundcube/",
|
||||||
"internet": "https://www.duckduckgo.com",
|
"internet": "https://www.duckduckgo.com",
|
||||||
"weather": "https://weather.com",
|
"weather": "weather.html",
|
||||||
"news": "https://news.google.com",
|
"news": "news.html",
|
||||||
"facebook": "https://www.facebook.com",
|
"facebook": "https://www.facebook.com",
|
||||||
"messenger": "https://www.messenger.com",
|
"messenger": "https://www.messenger.com",
|
||||||
"youtube": "https://www.youtube.com"
|
"youtube": "https://www.youtube.com"
|
||||||
|
|||||||
@@ -9,8 +9,30 @@ KIOSK_REMOTE="jgitta@100.64.0.1"
|
|||||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
HOME_SRC="$REPO_DIR/home-screen-index.html"
|
HOME_SRC="$REPO_DIR/home-screen-index.html"
|
||||||
HOME_DST="/home/jgitta/kiosk-home/index.html"
|
HOME_DST="/home/jgitta/kiosk-home/index.html"
|
||||||
|
WEATHER_SRC="$REPO_DIR/weather.html"
|
||||||
|
WEATHER_DST="/home/jgitta/kiosk-home/weather.html"
|
||||||
|
NEWS_SRC="$REPO_DIR/news.html"
|
||||||
|
NEWS_DST="/home/jgitta/kiosk-home/news.html"
|
||||||
CONFIG_SRC="$REPO_DIR/config.json"
|
CONFIG_SRC="$REPO_DIR/config.json"
|
||||||
COMMIT_MSG="${1:-deploy: update from kiosk-dev $(date +%Y-%m-%d)}"
|
# Auto-increment patch version (0.2.0 → 0.2.1 → 0.2.2 ...)
|
||||||
|
VERSION_FILE="$REPO_DIR/VERSION"
|
||||||
|
if [ -f "$VERSION_FILE" ]; then
|
||||||
|
CUR=$(cat "$VERSION_FILE" | tr -d '[:space:]')
|
||||||
|
MAJOR=$(echo "$CUR" | cut -d. -f1)
|
||||||
|
MINOR=$(echo "$CUR" | cut -d. -f2)
|
||||||
|
PATCH=$(echo "$CUR" | cut -d. -f3)
|
||||||
|
NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))"
|
||||||
|
echo "$NEW_VERSION" > "$VERSION_FILE"
|
||||||
|
echo "==> Version: $CUR → $NEW_VERSION"
|
||||||
|
else
|
||||||
|
NEW_VERSION="unknown"
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMMIT_MSG="${1:-deploy: v$NEW_VERSION $(date +%Y-%m-%d)}"
|
||||||
|
|
||||||
|
# Gitea credentials (from credentials.md)
|
||||||
|
GITEA_TOKEN="4fe93c6c62490ef0bb4e4aeec58f47da26752ce8"
|
||||||
|
GITEA_USER="jgitta"
|
||||||
|
|
||||||
inject_config() {
|
inject_config() {
|
||||||
local src="$1"
|
local src="$1"
|
||||||
@@ -19,7 +41,7 @@ inject_config() {
|
|||||||
|
|
||||||
if [ -f "$config" ]; then
|
if [ -f "$config" ]; then
|
||||||
local config_json=$(cat "$config" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | tr '\n' ' ')
|
local config_json=$(cat "$config" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | tr '\n' ' ')
|
||||||
sed "s|const config = {.*};|const config = $config_json;|" "$src" > "$dst"
|
sed "s|const config = {};|const config = $config_json;|" "$src" > "$dst"
|
||||||
echo " Injected config.json into home screen"
|
echo " Injected config.json into home screen"
|
||||||
else
|
else
|
||||||
cp "$src" "$dst"
|
cp "$src" "$dst"
|
||||||
@@ -36,13 +58,30 @@ else
|
|||||||
echo " WARNING: $HOME_SRC missing — home screen not updated"
|
echo " WARNING: $HOME_SRC missing — home screen not updated"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -f "$WEATHER_SRC" ]; then
|
||||||
|
cp "$WEATHER_SRC" "$WEATHER_DST"
|
||||||
|
chown jgitta:jgitta "$WEATHER_DST" 2>/dev/null || true
|
||||||
|
echo " Synced weather page"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$NEWS_SRC" ]; then
|
||||||
|
cp "$NEWS_SRC" "$NEWS_DST"
|
||||||
|
chown jgitta:jgitta "$NEWS_DST" 2>/dev/null || true
|
||||||
|
echo " Synced news page"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Committing and pushing to Gitea..."
|
echo "==> Committing and pushing to Gitea..."
|
||||||
git -C "$REPO_DIR" add -A
|
git -C "$REPO_DIR" add -A
|
||||||
git -C "$REPO_DIR" commit -m "$COMMIT_MSG" 2>/dev/null || echo " (nothing new to commit)"
|
git -C "$REPO_DIR" commit -m "$COMMIT_MSG" 2>/dev/null || echo " (nothing new to commit)"
|
||||||
git -C "$REPO_DIR" push origin master || echo " (nothing new to push)"
|
git -C "$REPO_DIR" remote set-url origin "http://$GITEA_USER:$GITEA_TOKEN@192.168.88.27:3002/jgitta/senior-kiosk.git"
|
||||||
|
git -C "$REPO_DIR" push origin master || echo " (push failed — check network)"
|
||||||
|
|
||||||
echo "==> Restarting kiosk-dev..."
|
echo "==> Restarting kiosk-dev..."
|
||||||
|
if [ -f "/usr/local/bin/kiosk-restart" ]; then
|
||||||
/usr/local/bin/kiosk-restart
|
/usr/local/bin/kiosk-restart
|
||||||
|
else
|
||||||
|
ssh kiosk-dev "/usr/local/bin/kiosk-restart"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Trying production kiosk..."
|
echo "==> Trying production kiosk..."
|
||||||
KIOSK=""
|
KIOSK=""
|
||||||
@@ -61,8 +100,10 @@ if [ -n "$KIOSK" ]; then
|
|||||||
cd /home/jgitta/kiosk-browser
|
cd /home/jgitta/kiosk-browser
|
||||||
git pull
|
git pull
|
||||||
# Inject config at deploy time
|
# Inject config at deploy time
|
||||||
config_json=\$(cat config.json | sed 's/\\\\/\\\\\\\\/g' | sed 's/\"/\\\\\"/g' | tr '\n' ' ')
|
config_json=\\\$(cat config.json | sed 's/\\\\\\\\/\\\\\\\\\\\\\\\\/g' | sed 's/\\\"/\\\\\\\"/g' | tr '\\\n' ' ')
|
||||||
sed \"s|const config = {.*};|const config = \$config_json;|\" home-screen-index.html > /home/jgitta/kiosk-home/index.html
|
sed \"s|const config = {};|const config = \\\$config_json;|\" home-screen-index.html > /home/jgitta/kiosk-home/index.html
|
||||||
|
cp weather.html /home/jgitta/kiosk-home/weather.html
|
||||||
|
cp news.html /home/jgitta/kiosk-home/news.html
|
||||||
/usr/local/bin/kiosk-restart
|
/usr/local/bin/kiosk-restart
|
||||||
"
|
"
|
||||||
echo " Production updated."
|
echo " Production updated."
|
||||||
|
|||||||
@@ -98,6 +98,8 @@
|
|||||||
<div class="grid" id="buttonGrid"></div>
|
<div class="grid" id="buttonGrid"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const config = {};
|
||||||
|
|
||||||
const buttons = [
|
const buttons = [
|
||||||
{ id: 'email', label: 'Email', icon: '✉️', class: 'btn-gmail' },
|
{ id: 'email', label: 'Email', icon: '✉️', class: 'btn-gmail' },
|
||||||
{ id: 'internet', label: 'Internet', icon: '🌐', class: 'btn-browse' },
|
{ id: 'internet', label: 'Internet', icon: '🌐', class: 'btn-browse' },
|
||||||
@@ -109,10 +111,10 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
function loadButtons() {
|
function loadButtons() {
|
||||||
const config = {};
|
|
||||||
const grid = document.getElementById('buttonGrid');
|
const grid = document.getElementById('buttonGrid');
|
||||||
|
const urls = config.urls || {};
|
||||||
buttons.forEach(btn => {
|
buttons.forEach(btn => {
|
||||||
const url = config.urls[btn.id];
|
const url = urls[btn.id] || '#';
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.className = `btn ${btn.class}`;
|
link.className = `btn ${btn.class}`;
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|||||||
+107
-123
@@ -1,161 +1,145 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Post-install script for SeniorNet Kiosk ISO
|
# SeniorNet Kiosk setup script.
|
||||||
# Runs after Debian installation completes
|
# Runs on first boot via kiosk-firstboot.service (which gives it a real TTY).
|
||||||
|
# Can also be run manually: sudo bash install-kiosk.sh
|
||||||
|
|
||||||
echo "==> SeniorNet Kiosk Post-Install"
|
REPO_BASE="https://gitea.jgitta.com/jgitta/senior-kiosk/raw/branch/master"
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " SeniorNet Kiosk — First-Boot Setup"
|
||||||
|
echo "============================================"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
REPO_DIR="/home/jgitta/kiosk-browser"
|
# ── Fixed user details ─────────────────────────────────────────────
|
||||||
HOME_DIR="/home/jgitta/kiosk-home"
|
KIOSK_USER="kiosk"
|
||||||
CHROMIUM_PROFILE="/home/jgitta/chromium-kiosk"
|
KIOSK_NAME="Kiosk"
|
||||||
|
echo
|
||||||
|
|
||||||
# Ensure jgitta user exists
|
# ── Paths ──────────────────────────────────────────────────────────
|
||||||
if ! id "jgitta" &>/dev/null; then
|
KIOSK_HOME="/home/$KIOSK_USER"
|
||||||
useradd -m -s /bin/bash jgitta
|
REPO_DIR="$KIOSK_HOME/kiosk-browser"
|
||||||
|
HOME_DIR="$KIOSK_HOME/kiosk-home"
|
||||||
|
CHROMIUM_PROFILE="$KIOSK_HOME/chromium-kiosk"
|
||||||
|
|
||||||
|
# ── User ───────────────────────────────────────────────────────────
|
||||||
|
echo "==> Creating user $KIOSK_USER..."
|
||||||
|
if ! id "$KIOSK_USER" &>/dev/null; then
|
||||||
|
useradd -m -s /bin/bash -c "$KIOSK_NAME" "$KIOSK_USER"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install packages
|
# ── Packages ───────────────────────────────────────────────────────
|
||||||
echo "==> Installing dependencies..."
|
echo "==> Installing packages..."
|
||||||
apt-get update
|
sed -i '/^deb cdrom:/d' /etc/apt/sources.list
|
||||||
apt-get install -y python3 python3-pip chromium openbox xserver-xorg xserver-xorg-video-dummy xinit xdotool xprop git sqlite3 xvfb 2>&1 | grep -v "^Reading"
|
apt-get update -qq
|
||||||
|
apt-get install -y \
|
||||||
|
chromium \
|
||||||
|
openbox \
|
||||||
|
lightdm \
|
||||||
|
xserver-xorg \
|
||||||
|
x11-utils \
|
||||||
|
xdotool \
|
||||||
|
python3 \
|
||||||
|
python3-pyqt5 \
|
||||||
|
python3-websocket \
|
||||||
|
sqlite3 \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
openssh-server \
|
||||||
|
fonts-noto-color-emoji \
|
||||||
|
2>&1 | grep -Ev "^(Reading|Selecting|Preparing|Unpacking|Setting|Processing|Triggers|Get)"
|
||||||
|
|
||||||
# Install Python packages
|
# ── Kiosk repo ─────────────────────────────────────────────────────
|
||||||
echo "==> Installing Python packages..."
|
echo "==> Cloning kiosk repo..."
|
||||||
pip3 install --break-system-packages PyQt5 websocket-client 2>&1 | tail -5
|
|
||||||
|
|
||||||
# Clone repo
|
|
||||||
echo "==> Cloning repository..."
|
|
||||||
if [ ! -d "$REPO_DIR" ]; then
|
if [ ! -d "$REPO_DIR" ]; then
|
||||||
git clone https://gitea.jgitta.com/jgitta/senior-kiosk.git "$REPO_DIR"
|
git clone --depth=1 https://gitea.jgitta.com/jgitta/senior-kiosk.git "$REPO_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$REPO_DIR"
|
# ── Home screen ────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Deploy home screen with config
|
|
||||||
echo "==> Deploying home screen..."
|
echo "==> Deploying home screen..."
|
||||||
mkdir -p "$HOME_DIR"
|
mkdir -p "$HOME_DIR"
|
||||||
if [ -f config.json ] && [ -f home-screen-index.html ]; then
|
config_json=$(cat "$REPO_DIR/config.json" | tr '\n' ' ')
|
||||||
config_json=$(cat config.json | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | tr '\n' ' ')
|
sed "s|const config = {};|const config = $config_json;|" \
|
||||||
sed "s|const config = {};|const config = $config_json;|" home-screen-index.html > "$HOME_DIR/index.html"
|
"$REPO_DIR/home-screen-index.html" > "$HOME_DIR/index.html"
|
||||||
chown jgitta:jgitta "$HOME_DIR/index.html"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create Chromium profile directory
|
|
||||||
mkdir -p "$CHROMIUM_PROFILE"
|
|
||||||
chown -R jgitta:jgitta "$CHROMIUM_PROFILE"
|
|
||||||
|
|
||||||
# Systemd units
|
# ── LightDM auto-login ─────────────────────────────────────────────
|
||||||
echo "==> Installing systemd units..."
|
echo "==> Configuring LightDM auto-login..."
|
||||||
|
mkdir -p /etc/lightdm/lightdm.conf.d
|
||||||
|
cat > /etc/lightdm/lightdm.conf.d/50-kiosk.conf << EOF
|
||||||
|
[Seat:*]
|
||||||
|
autologin-user=$KIOSK_USER
|
||||||
|
autologin-user-timeout=0
|
||||||
|
user-session=openbox
|
||||||
|
EOF
|
||||||
|
|
||||||
cat > /etc/systemd/system/kiosk-display.service << 'UNIT1'
|
# ── Openbox ────────────────────────────────────────────────────────
|
||||||
[Unit]
|
|
||||||
Description=SeniorNet Kiosk Display
|
|
||||||
Before=kiosk.service
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=forking
|
|
||||||
User=jgitta
|
|
||||||
ExecStart=/bin/bash -c 'Xvfb :0 -screen 0 1920x1080x24 -ac &'
|
|
||||||
ExecStartPost=/bin/sleep 2
|
|
||||||
ExecStartPost=/bin/bash -c 'DISPLAY=:0 su - jgitta -c "openbox &"'
|
|
||||||
ExecStartPost=/bin/sleep 1
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
UNIT1
|
|
||||||
|
|
||||||
cat > /etc/systemd/system/kiosk.service << 'UNIT2'
|
|
||||||
[Unit]
|
|
||||||
Description=SeniorNet Kiosk
|
|
||||||
After=network.target kiosk-display.service
|
|
||||||
Wants=kiosk-display.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=jgitta
|
|
||||||
Environment="DISPLAY=:0"
|
|
||||||
WorkingDirectory=/home/jgitta/kiosk-browser
|
|
||||||
ExecStart=/usr/bin/python3 /home/jgitta/kiosk-browser/browser.py
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
UNIT2
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable kiosk-display.service kiosk.service
|
|
||||||
|
|
||||||
# kiosk-restart helper
|
|
||||||
cat > /usr/local/bin/kiosk-restart << 'HELPER'
|
|
||||||
#!/bin/bash
|
|
||||||
pkill -f "remote-debugging-port=9222" || true
|
|
||||||
pkill -f "python3.*browser" || true
|
|
||||||
sleep 1
|
|
||||||
systemctl restart kiosk.service
|
|
||||||
HELPER
|
|
||||||
chmod +x /usr/local/bin/kiosk-restart
|
|
||||||
|
|
||||||
# Openbox config
|
|
||||||
echo "==> Configuring Openbox..."
|
echo "==> Configuring Openbox..."
|
||||||
mkdir -p /home/jgitta/.config/openbox
|
mkdir -p "$KIOSK_HOME/.config/openbox"
|
||||||
|
|
||||||
cat > /home/jgitta/.config/openbox/rc.xml << 'OPENBOX'
|
cat > "$KIOSK_HOME/.config/openbox/rc.xml" << 'EOF'
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<openbox_config xmlns="http://openbox.org/3.4/rc">
|
<openbox_config xmlns="http://openbox.org/3.4/rc">
|
||||||
<startup/>
|
<startup/>
|
||||||
</openbox_config>
|
</openbox_config>
|
||||||
OPENBOX
|
EOF
|
||||||
|
|
||||||
cat > /home/jgitta/.xinitrc << 'XINITRC'
|
cat > "$KIOSK_HOME/.config/openbox/autostart" << 'EOF'
|
||||||
|
(while true; do
|
||||||
|
python3 ~/kiosk-browser/browser.py
|
||||||
|
sleep 2
|
||||||
|
done) &
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── kiosk-restart helper ───────────────────────────────────────────
|
||||||
|
cat > /usr/local/bin/kiosk-restart << 'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
exec openbox
|
pkill -f "remote-debugging-port=9222" || true
|
||||||
XINITRC
|
pkill -f "python3.*browser" || true
|
||||||
chmod +x /home/jgitta/.xinitrc
|
sleep 1
|
||||||
|
echo "Kiosk restarted — new code is live"
|
||||||
# X11 hardening
|
EOF
|
||||||
echo "==> Configuring X11..."
|
chmod +x /usr/local/bin/kiosk-restart
|
||||||
cat > /etc/X11/xorg.conf.d/50-kiosk.conf << 'XORG'
|
|
||||||
Section "Device"
|
|
||||||
Identifier "Dummy0"
|
|
||||||
Driver "dummy"
|
|
||||||
EndSection
|
|
||||||
|
|
||||||
Section "Monitor"
|
|
||||||
Identifier "Monitor0"
|
|
||||||
HorizSync 30-80
|
|
||||||
VertRefresh 30-75
|
|
||||||
EndSection
|
|
||||||
|
|
||||||
Section "Screen"
|
|
||||||
Identifier "Screen0"
|
|
||||||
Device "Dummy0"
|
|
||||||
Monitor "Monitor0"
|
|
||||||
DefaultDepth 24
|
|
||||||
SubSection "Display"
|
|
||||||
Depth 24
|
|
||||||
Modes "1920x1080"
|
|
||||||
EndSubSection
|
|
||||||
EndSection
|
|
||||||
|
|
||||||
|
# ── X11 hardening ──────────────────────────────────────────────────
|
||||||
|
echo "==> Hardening X11..."
|
||||||
|
mkdir -p /etc/X11/xorg.conf.d
|
||||||
|
cat > /etc/X11/xorg.conf.d/50-kiosk.conf << 'EOF'
|
||||||
Section "ServerFlags"
|
Section "ServerFlags"
|
||||||
Option "DontVTSwitch" "true"
|
Option "DontVTSwitch" "true"
|
||||||
|
Option "DontZap" "true"
|
||||||
Option "BlankTime" "0"
|
Option "BlankTime" "0"
|
||||||
Option "StandbyTime" "0"
|
Option "StandbyTime" "0"
|
||||||
EndSection
|
EndSection
|
||||||
XORG
|
EOF
|
||||||
|
|
||||||
# Permissions
|
# ── SSH: allow root login for admin ───────────────────────────────
|
||||||
|
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# ── Permissions ────────────────────────────────────────────────────
|
||||||
echo "==> Setting permissions..."
|
echo "==> Setting permissions..."
|
||||||
chown -R jgitta:jgitta /home/jgitta/.config /home/jgitta/.xinitrc "$REPO_DIR" "$HOME_DIR"
|
chown -R "$KIOSK_USER:$KIOSK_USER" \
|
||||||
|
"$KIOSK_HOME/.config" \
|
||||||
|
"$REPO_DIR" \
|
||||||
|
"$HOME_DIR"
|
||||||
|
|
||||||
# Enable getty on ttys for troubleshooting (optional, comment out for full lockdown)
|
# ── Enable LightDM, remove first-boot setup ───────────────────────
|
||||||
systemctl set-default multi-user.target
|
systemctl enable lightdm
|
||||||
|
systemctl set-default graphical.target
|
||||||
|
# Remove auto-login and setup script so root doesn't auto-login after kiosk is running
|
||||||
|
rm -f /etc/systemd/system/getty@tty1.service.d/autologin.conf
|
||||||
|
rm -f /usr/local/bin/kiosk-setup
|
||||||
|
# Legacy cleanup (no-op if not present)
|
||||||
|
systemctl disable kiosk-firstboot 2>/dev/null || true
|
||||||
|
rm -f /etc/systemd/system/kiosk-firstboot.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "==> Post-install complete"
|
echo "============================================"
|
||||||
echo " Machine will auto-start kiosk on next boot"
|
echo " Setup complete!"
|
||||||
|
echo " Kiosk will start after reboot as: $KIOSK_NAME ($KIOSK_USER)"
|
||||||
|
echo "============================================"
|
||||||
echo
|
echo
|
||||||
|
sync
|
||||||
|
reboot
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>News</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d);
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: white;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extra-wide custom high-contrast scrollbar for senior usability */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Frosted glass container */
|
||||||
|
.container {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 36px;
|
||||||
|
padding: 40px 30px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #ffd700;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading-box {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 26px;
|
||||||
|
padding: 60px 0;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #38bdf8;
|
||||||
|
margin: 40px 0 20px 0;
|
||||||
|
border-bottom: 3px solid rgba(56, 189, 248, 0.3);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accordion stories list */
|
||||||
|
.stories-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: background 0.2s, transform 0.15s;
|
||||||
|
scroll-margin-top: 20px; /* Keep beautiful breathing room at the top of viewport when focused */
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-card:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.09);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Large tap target for header */
|
||||||
|
.story-header {
|
||||||
|
padding: 24px 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-headline {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-chevron {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #94a3b8;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-card.open .story-chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth accordion drawer */
|
||||||
|
.story-body {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.35s ease-in-out;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-card.open .story-body {
|
||||||
|
/* Transition is controlled dynamically via JS max-height for perfect animations */
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-content {
|
||||||
|
padding: 24px 28px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-summary {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-meta {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Giant Home Button */
|
||||||
|
.footer-nav {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 22px 55px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.35);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.1s, box-shadow 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-btn:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 12px 30px rgba(16, 185, 129, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-btn:active {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>📰 Daily Newspaper</h1>
|
||||||
|
<p id="newspaper-date">Today's Edition</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="loading-box">🗞️ Delivery in progress... Please wait.</div>
|
||||||
|
|
||||||
|
<div id="news-content" style="display: none;">
|
||||||
|
<!-- World News Section -->
|
||||||
|
<div class="category-title">🌐 World News</div>
|
||||||
|
<div class="stories-list" id="world-news-list"></div>
|
||||||
|
|
||||||
|
<!-- National News Section -->
|
||||||
|
<div class="category-title">🇺🇸 National News</div>
|
||||||
|
<div class="stories-list" id="national-news-list"></div>
|
||||||
|
|
||||||
|
<!-- High-viewport spacer to guarantee that even the last stories in the list can scroll perfectly to the top headline -->
|
||||||
|
<div style="height: 75vh;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-nav">
|
||||||
|
<a href="index.html" class="home-btn">⌂ Go Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Format publication dates cleanly
|
||||||
|
function formatToday() {
|
||||||
|
const now = new Date();
|
||||||
|
return now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNews() {
|
||||||
|
document.getElementById('newspaper-date').textContent = formatToday();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch("news-data.json");
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
renderCategory("World News", data["World News"], "world-news-list");
|
||||||
|
renderCategory("National News", data["National News"], "national-news-list");
|
||||||
|
|
||||||
|
document.getElementById('loading-box').style.display = 'none';
|
||||||
|
document.getElementById('news-content').style.display = 'block';
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('loading-box').textContent = "⚠️ Preparing newspaper... Please wait a moment.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCategory(catName, stories, containerId) {
|
||||||
|
const box = document.getElementById(containerId);
|
||||||
|
box.innerHTML = ""; // Clear loader
|
||||||
|
|
||||||
|
if (!stories || stories.length === 0) {
|
||||||
|
box.innerHTML = `<div style="padding: 20px; font-size: 20px; color: #94a3b8; text-align: center;">No stories available.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stories.forEach((story, idx) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'story-card';
|
||||||
|
|
||||||
|
// Wrap each scraped paragraph in a comfortable <p> block for readability
|
||||||
|
const formattedSummary = story.summary
|
||||||
|
.split('\n\n')
|
||||||
|
.map(para => `<p style="margin-bottom:18px;">${para}</p>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="story-header" onclick="toggleStory(this)">
|
||||||
|
<div class="story-headline">${story.title}</div>
|
||||||
|
<div class="story-chevron">▸</div>
|
||||||
|
</div>
|
||||||
|
<div class="story-body">
|
||||||
|
<div class="story-content">
|
||||||
|
${story.image ? `<img src="${story.image}" style="width:100%; max-height:450px; object-fit:cover; border-radius:16px; margin-bottom:24px; box-shadow: 0 4px 15px rgba(0,0,0,0.35);" onerror="this.style.display='none'">` : ''}
|
||||||
|
<div class="story-summary">${formattedSummary}</div>
|
||||||
|
<div class="story-meta">📰 Source: ${story.source || 'Daily Digest'} | ⌚ Published: ${story.date || 'Recent'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
box.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive in-place accordion expansion with scroll-to-focus
|
||||||
|
function toggleStory(headerElement) {
|
||||||
|
const card = headerElement.parentElement;
|
||||||
|
const body = card.querySelector('.story-body');
|
||||||
|
const isOpen = card.classList.contains('open');
|
||||||
|
|
||||||
|
// Close ALL other story cards globally across all categories
|
||||||
|
const allCards = document.querySelectorAll('.story-card');
|
||||||
|
allCards.forEach(c => {
|
||||||
|
if (c !== card && c.classList.contains('open')) {
|
||||||
|
c.classList.remove('open');
|
||||||
|
const b = c.querySelector('.story-body');
|
||||||
|
if (b) {
|
||||||
|
b.style.transition = 'none';
|
||||||
|
b.style.maxHeight = '0px';
|
||||||
|
b.offsetHeight; // Force reflow
|
||||||
|
b.style.transition = ''; // Restore transition for next open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
card.classList.add('open');
|
||||||
|
if (body) {
|
||||||
|
body.style.maxHeight = body.scrollHeight + 'px';
|
||||||
|
}
|
||||||
|
// Smoothly scroll the newly opened story card to the top of the viewport
|
||||||
|
setTimeout(() => {
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}, 50); // Small delay to allow reflow, works perfectly and instantly
|
||||||
|
} else {
|
||||||
|
card.classList.remove('open');
|
||||||
|
if (body) {
|
||||||
|
body.style.maxHeight = '0px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onload = loadNews;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Downloaded and run by the preseed late_command after Debian base install.
|
||||||
|
# Fetches install-kiosk.sh, configures getty@tty1 to auto-login as root,
|
||||||
|
# and runs the setup wizard automatically on first reboot.
|
||||||
|
|
||||||
|
REPO_BASE="https://gitea.jgitta.com/jgitta/senior-kiosk/raw/branch/master"
|
||||||
|
|
||||||
|
echo "==> SeniorNet: downloading setup script..."
|
||||||
|
curl -fsSL "$REPO_BASE/install-kiosk.sh" -o /usr/local/bin/kiosk-setup
|
||||||
|
chmod +x /usr/local/bin/kiosk-setup
|
||||||
|
|
||||||
|
echo "==> SeniorNet: configuring auto-login on TTY1..."
|
||||||
|
mkdir -p /etc/systemd/system/getty@tty1.service.d
|
||||||
|
cat > /etc/systemd/system/getty@tty1.service.d/autologin.conf << 'EOF'
|
||||||
|
[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=-/sbin/agetty --autologin root --noclear %I $TERM
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "==> SeniorNet: configuring setup to run on first login..."
|
||||||
|
cat >> /root/.bash_profile << 'EOF'
|
||||||
|
|
||||||
|
# SeniorNet first-boot setup — always pulls latest from Gitea, runs once
|
||||||
|
if [ -f /usr/local/bin/kiosk-setup ]; then
|
||||||
|
curl -fsSL https://gitea.jgitta.com/jgitta/senior-kiosk/raw/branch/master/install-kiosk.sh \
|
||||||
|
-o /usr/local/bin/kiosk-setup 2>/dev/null || true
|
||||||
|
bash /usr/local/bin/kiosk-setup
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Allow root SSH login with password for debugging during setup
|
||||||
|
echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config
|
||||||
|
echo 'PasswordAuthentication yes' >> /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# Boot to text mode for the setup wizard
|
||||||
|
systemctl set-default multi-user.target
|
||||||
|
|
||||||
|
echo "==> SeniorNet: ready. Setup wizard will run automatically on next reboot."
|
||||||
+42
-26
@@ -1,58 +1,74 @@
|
|||||||
# Debian Preseed — SeniorNet Kiosk
|
# Debian Preseed — SeniorNet Kiosk
|
||||||
# Silent/unattended installer configuration
|
# Silent unattended Debian base install.
|
||||||
|
# After install, downloads preseed-bootstrap.sh which installs a first-boot
|
||||||
|
# setup wizard. On first reboot the wizard prompts for username/name,
|
||||||
|
# installs the kiosk, then reboots into kiosk mode.
|
||||||
|
|
||||||
# Locale and keyboard
|
# ── Locale / keyboard ──────────────────────────────────────────────
|
||||||
d-i debian-installer/locale string en_US.UTF-8
|
d-i debian-installer/locale string en_US.UTF-8
|
||||||
d-i keyboard-configuration/xkb-keymap select us
|
d-i keyboard-configuration/xkb-keymap select us
|
||||||
|
|
||||||
# Network
|
# ── Network ────────────────────────────────────────────────────────
|
||||||
|
# Auto, but early_command will pin to the first wired (eth/en) interface
|
||||||
|
# if one exists — prevents installer from defaulting to WiFi and asking
|
||||||
|
# for a WPA passphrase. Ethernet must be plugged in during install.
|
||||||
d-i netcfg/choose_interface select auto
|
d-i netcfg/choose_interface select auto
|
||||||
d-i netcfg/get_hostname string kiosk
|
d-i netcfg/get_hostname string kiosk
|
||||||
d-i netcfg/get_domain string local
|
d-i netcfg/get_domain string local
|
||||||
d-i netcfg/hostname string kiosk
|
|
||||||
|
|
||||||
# Mirror
|
# ── Mirror ─────────────────────────────────────────────────────────
|
||||||
d-i mirror/country string manual
|
d-i mirror/country string manual
|
||||||
d-i mirror/http/hostname string deb.debian.org
|
d-i mirror/http/hostname string deb.debian.org
|
||||||
d-i mirror/http/directory string /debian
|
d-i mirror/http/directory string /debian
|
||||||
d-i mirror/http/proxy string
|
d-i mirror/http/proxy string
|
||||||
|
|
||||||
# Partitioning — use entire disk
|
# ── Clock / timezone ───────────────────────────────────────────────
|
||||||
d-i partman-auto/method string lvm
|
|
||||||
d-i partman-lvm/device_remove_lvm boolean true
|
|
||||||
d-i partman-auto/purge_lvm_confirmation boolean true
|
|
||||||
d-i partman-auto/disk string /dev/sda
|
|
||||||
d-i partman-auto/choose_recipe select atomic
|
|
||||||
d-i partman/confirm_write_new_label boolean true
|
|
||||||
d-i partman/confirm boolean true
|
|
||||||
|
|
||||||
# Clock/timezone
|
|
||||||
d-i time/zone string UTC
|
d-i time/zone string UTC
|
||||||
d-i clock-setup/utc boolean true
|
d-i clock-setup/utc boolean true
|
||||||
d-i clock-setup/ntp boolean true
|
d-i clock-setup/ntp boolean true
|
||||||
|
|
||||||
# Account setup — root with no password
|
# ── Accounts ───────────────────────────────────────────────────────
|
||||||
|
# Root only — install-kiosk.sh creates the kiosk user interactively.
|
||||||
d-i passwd/root-login boolean true
|
d-i passwd/root-login boolean true
|
||||||
d-i passwd/make-user boolean false
|
d-i passwd/make-user boolean false
|
||||||
d-i passwd/root-password password kiosk
|
d-i passwd/root-password password kiosk
|
||||||
d-i passwd/root-password-again password kiosk
|
d-i passwd/root-password-again password kiosk
|
||||||
|
|
||||||
# APT
|
# ── Disk — auto-detect first real disk ─────────────────────────────
|
||||||
|
# Handles /dev/sda, /dev/nvme0n1, /dev/vda, etc.
|
||||||
|
d-i preseed/early_command string \
|
||||||
|
DISK=$(ls /sys/block/ | grep -Ev '^(loop|sr|fd|ram)' | head -1); \
|
||||||
|
debconf-set partman-auto/disk "/dev/$DISK" || true; \
|
||||||
|
debconf-set grub-installer/bootdev "/dev/$DISK" || true; \
|
||||||
|
ETH=$(ls /sys/class/net/ 2>/dev/null | grep -E '^(eth|en)' | head -1 || true); \
|
||||||
|
if [ -n "$ETH" ]; then debconf-set netcfg/choose_interface "$ETH" || true; fi; \
|
||||||
|
true
|
||||||
|
|
||||||
|
d-i partman-auto/method string regular
|
||||||
|
d-i partman-auto/choose_recipe select atomic
|
||||||
|
d-i partman-partitioning/confirm_write_new_label boolean true
|
||||||
|
d-i partman/choose_partition select finish
|
||||||
|
d-i partman/confirm_write_new_label boolean true
|
||||||
|
d-i partman/confirm boolean true
|
||||||
|
d-i partman/confirm_nooverwrite boolean true
|
||||||
|
|
||||||
|
# ── APT ────────────────────────────────────────────────────────────
|
||||||
d-i apt-setup/services-select multiselect security, updates
|
d-i apt-setup/services-select multiselect security, updates
|
||||||
d-i apt-setup/security_host string security.debian.org
|
d-i apt-setup/security_host string security.debian.org
|
||||||
|
|
||||||
# Package selection
|
# ── Package selection ──────────────────────────────────────────────
|
||||||
tasksel tasksel/first multiselect standard, ssh-server
|
tasksel tasksel/first multiselect standard, ssh-server
|
||||||
d-i pkgsel/include string openssh-server build-essential git curl wget
|
d-i pkgsel/include string curl git
|
||||||
|
|
||||||
# Grub bootloader
|
# ── Bootloader ─────────────────────────────────────────────────────
|
||||||
d-i grub-installer/only boolean true
|
d-i grub-installer/only boolean true
|
||||||
d-i grub-installer/bootdev string default
|
d-i grub-installer/bootdev string default
|
||||||
|
|
||||||
# Finish installation
|
# ── Post-install bootstrap ─────────────────────────────────────────
|
||||||
d-i finish-install/reboot_in_background boolean true
|
# Downloads preseed-bootstrap.sh which installs the first-boot setup service.
|
||||||
|
|
||||||
# Run post-install script
|
|
||||||
d-i preseed/late_command string \
|
d-i preseed/late_command string \
|
||||||
in-target bash -c 'curl -fsSL https://gitea.jgitta.com/jgitta/senior-kiosk/raw/master/install-kiosk.sh | bash' || \
|
in-target bash -c 'curl -fsSL https://gitea.jgitta.com/jgitta/senior-kiosk/raw/branch/master/preseed-bootstrap.sh | bash' ; \
|
||||||
in-target bash -c 'cd /root && git clone https://gitea.jgitta.com/jgitta/senior-kiosk.git && bash senior-kiosk/install-kiosk.sh'
|
(sleep 3 && reboot) &
|
||||||
|
|
||||||
|
# ── Finish ─────────────────────────────────────────────────────────
|
||||||
|
# reboot is triggered by late_command background job, bypassing the final dialog
|
||||||
|
|||||||
+299
@@ -0,0 +1,299 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Weather</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1e3c72, #2a5298);
|
||||||
|
color: white;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Frosted glass container card */
|
||||||
|
.container {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 32px;
|
||||||
|
padding: 40px 30px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-name {
|
||||||
|
font-size: 38px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #e0f2fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading-text {
|
||||||
|
font-size: 26px;
|
||||||
|
margin: 40px 0;
|
||||||
|
color: #aac8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 30px;
|
||||||
|
margin: 30px 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-temp {
|
||||||
|
font-size: 88px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-icon {
|
||||||
|
font-size: 96px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-desc {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #38bdf8;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feels-like {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info pills */
|
||||||
|
.details-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 14px 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forecast layout */
|
||||||
|
.forecast-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forecast-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forecast-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-day {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-temp {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Go Home Navigation button */
|
||||||
|
.home-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 20px 50px;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s, box-shadow 0.1s;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-btn:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 10px 25px rgba(16, 185, 129, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-btn:active {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container" id="weather-container">
|
||||||
|
<div id="loading-box">
|
||||||
|
<div id="loading-text">🔍 Determining your location...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="weather-content" style="display: none;">
|
||||||
|
<div id="location-name">Detecting Location...</div>
|
||||||
|
|
||||||
|
<div class="weather-main">
|
||||||
|
<div class="weather-icon" id="main-icon">🌤️</div>
|
||||||
|
<div class="main-temp" id="temp-val">--°F</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="condition-desc" id="condition-txt">Partly Cloudy</div>
|
||||||
|
<div class="feels-like" id="feels-like-txt">Feels like --°F</div>
|
||||||
|
|
||||||
|
<div class="details-row">
|
||||||
|
<div class="pill">💧 Humidity: <span id="humidity-val">--%</span></div>
|
||||||
|
<div class="pill">💨 Wind: <span id="wind-val">-- mph</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="forecast-title">3-Day Forecast</div>
|
||||||
|
<div class="forecast-grid" id="forecast-box"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="index.html" class="home-btn">⌂ Go Home</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// WMO Weather interpretation codes mapping
|
||||||
|
const WEATHER_CODES = {
|
||||||
|
0: { txt: "Sunny", emoji: "☀️" },
|
||||||
|
1: { txt: "Mainly Clear", emoji: "🌤️" },
|
||||||
|
2: { txt: "Partly Cloudy", emoji: "⛅" },
|
||||||
|
3: { txt: "Overcast", emoji: "☁️" },
|
||||||
|
45: { txt: "Foggy", emoji: "🌫️" },
|
||||||
|
48: { txt: "Depositing Rime Fog", emoji: "🌫️" },
|
||||||
|
51: { txt: "Light Drizzle", emoji: "🌦️" },
|
||||||
|
53: { txt: "Moderate Drizzle", emoji: "🌦️" },
|
||||||
|
55: { txt: "Dense Drizzle", emoji: "🌦️" },
|
||||||
|
61: { txt: "Slight Rain", emoji: "🌧️" },
|
||||||
|
63: { txt: "Moderate Rain", emoji: "🌧️" },
|
||||||
|
65: { txt: "Heavy Rain", emoji: "🌧️" },
|
||||||
|
71: { txt: "Light Snowfall", emoji: "🌨️" },
|
||||||
|
73: { txt: "Moderate Snowfall", emoji: "🌨️" },
|
||||||
|
75: { txt: "Heavy Snowfall", emoji: "🌨️" },
|
||||||
|
77: { txt: "Snow Grains", emoji: "🌨️" },
|
||||||
|
80: { txt: "Slight Showers", emoji: "🌧️" },
|
||||||
|
81: { txt: "Moderate Showers", emoji: "🌧️" },
|
||||||
|
82: { txt: "Violent Showers", emoji: "⛈️" },
|
||||||
|
85: { txt: "Slight Snow Showers", emoji: "🌨️" },
|
||||||
|
86: { txt: "Heavy Snow Showers", emoji: "🌨️" },
|
||||||
|
95: { txt: "Thunderstorm", emoji: "⛈️" },
|
||||||
|
96: { txt: "Thunderstorm with Hail", emoji: "⛈️" },
|
||||||
|
99: { txt: "Thunderstorm with Heavy Hail", emoji: "⛈️" }
|
||||||
|
};
|
||||||
|
|
||||||
|
function getWeatherInfo(code) {
|
||||||
|
return WEATHER_CODES[code] || { txt: "Weather Details", emoji: "🌤️" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initWeather() {
|
||||||
|
try {
|
||||||
|
document.getElementById('loading-text').textContent = "📊 Loading local weather forecasts...";
|
||||||
|
const resp = await fetch("weather-data.json");
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
const locName = data.location || "Your Area";
|
||||||
|
const fc = data.forecast;
|
||||||
|
|
||||||
|
// Render current stats
|
||||||
|
const current = fc.current;
|
||||||
|
const wInfo = getWeatherInfo(current.weather_code);
|
||||||
|
|
||||||
|
document.getElementById('location-name').textContent = locName;
|
||||||
|
document.getElementById('temp-val').textContent = `${Math.round(current.temperature_2m)}°F`;
|
||||||
|
document.getElementById('main-icon').textContent = wInfo.emoji;
|
||||||
|
document.getElementById('condition-txt').textContent = wInfo.txt;
|
||||||
|
document.getElementById('feels-like-txt').textContent = `Feels like ${Math.round(current.apparent_temperature)}°F`;
|
||||||
|
document.getElementById('humidity-val').textContent = `${current.relative_humidity_2m}%`;
|
||||||
|
document.getElementById('wind-val').textContent = `${Math.round(current.wind_speed_10m)} mph`;
|
||||||
|
|
||||||
|
// Render 3-Day Forecast
|
||||||
|
const daily = fc.daily;
|
||||||
|
const forecastBox = document.getElementById('forecast-box');
|
||||||
|
forecastBox.innerHTML = ""; // Clear loader
|
||||||
|
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const dateStr = daily.time[i];
|
||||||
|
const dateObj = new Date(dateStr + "T00:00:00");
|
||||||
|
const dayName = dateObj.toLocaleDateString('en-US', { weekday: 'long' });
|
||||||
|
const code = daily.weather_code[i];
|
||||||
|
const fcInfo = getWeatherInfo(code);
|
||||||
|
const maxT = Math.round(daily.temperature_2m_max[i]);
|
||||||
|
const minT = Math.round(daily.temperature_2m_min[i]);
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'forecast-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="fc-day">${dayName}</div>
|
||||||
|
<div class="fc-icon">${fcInfo.emoji}</div>
|
||||||
|
<div class="fc-temp">🔆 ${maxT}° / 🌙 ${minT}°F</div>
|
||||||
|
`;
|
||||||
|
forecastBox.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide loader and show content
|
||||||
|
document.getElementById('loading-box').style.display = 'none';
|
||||||
|
document.getElementById('weather-content').style.display = 'block';
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('loading-text').textContent = "⚠️ Preparing weather data... Please wait a moment.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onload = initWeather;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user