feat: add local ad-free weather page
This commit is contained in:
+232
-257
@@ -10,8 +10,7 @@ from PyQt5.QtGui import QFont, QColor, QPalette, QCursor
|
|||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QApplication, QWidget, QHBoxLayout, QVBoxLayout,
|
QApplication, QWidget, QHBoxLayout, QVBoxLayout,
|
||||||
QPushButton, QLabel, QScrollArea, QDialog, QLineEdit,
|
QPushButton, QLabel, QScrollArea, QDialog, QLineEdit,
|
||||||
QMessageBox, QFrame,
|
QMessageBox, QFrame, QTreeWidget, QTreeWidgetItem, QAbstractItemView
|
||||||
QTreeWidget, QTreeWidgetItem, QAbstractItemView
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_HOME = pathlib.Path.home()
|
_HOME = pathlib.Path.home()
|
||||||
@@ -22,54 +21,112 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
_VERSION = "?"
|
_VERSION = "?"
|
||||||
CDP_PORT = 9222
|
CDP_PORT = 9222
|
||||||
TOOLBAR_H = 72 # single row — was 142 (82 + 60)
|
TOOLBAR_H = 72
|
||||||
# Screen size is detected dynamically in main() after QApplication starts
|
SCREEN_W = 1920 # Dynamically updated in main
|
||||||
SCREEN_W = 1920 # overwritten at runtime
|
SCREEN_H = 1080 # Dynamically updated in main
|
||||||
SCREEN_H = 1080 # overwritten at runtime
|
|
||||||
|
|
||||||
# ── Colour palette ────────────────────────────────────────────────
|
# ── Styling and Themes (DRY Style System) ─────────────────────────
|
||||||
TOOLBAR_COLOR = "#1a3a5c"
|
THEME = {
|
||||||
BTN_COLOR = "#2e6da4"
|
"colors": {
|
||||||
BTN_HOVER = "#3a87cc"
|
"toolbar": "#1a3a5c",
|
||||||
GOLD = "#e8a020"
|
"btn": "#2e6da4",
|
||||||
GOLD_HOVER = "#f0b030"
|
"btn_hover": "#3a87cc",
|
||||||
GREEN = "#2e7d32"
|
"gold": "#e8a020",
|
||||||
GREEN_HOVER = "#388e3c"
|
"gold_hover": "#f0b030",
|
||||||
RED = "#c62828"
|
"green": "#2e7d32",
|
||||||
RED_HOVER = "#d32f2f"
|
"green_hover": "#388e3c",
|
||||||
AMBER = "#7a5a10"
|
"red": "#c62828",
|
||||||
AMBER_HOVER = "#9a7a20"
|
"red_hover": "#d32f2f",
|
||||||
PURPLE = "#5a5a8a"
|
"amber": "#7a5a10",
|
||||||
PURPLE_HOVER = "#7a7aaa"
|
"amber_hover": "#9a7a20",
|
||||||
FONT_SIZE = 18
|
"purple": "#5a5a8a",
|
||||||
|
"purple_hover": "#7a7aaa",
|
||||||
|
"bg_dark": "#1e2a3a",
|
||||||
|
"bg_input": "#2a3f55",
|
||||||
|
"border": "#3a6da4",
|
||||||
|
"text_secondary": "#aac8e8",
|
||||||
|
},
|
||||||
|
"font_size": 18
|
||||||
|
}
|
||||||
|
|
||||||
|
STYLES = {
|
||||||
|
"button": """
|
||||||
|
QPushButton {{
|
||||||
|
background: {color}; color: white; border: none;
|
||||||
|
border-radius: 10px; padding: 6px 14px;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background: {hover}; }}
|
||||||
|
QPushButton:pressed {{ background: #222; }}
|
||||||
|
QPushButton:disabled {{ background: #444; color: #888; }}
|
||||||
|
""",
|
||||||
|
"icon_button": """
|
||||||
|
QPushButton {{
|
||||||
|
background: {color}; color: white; border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background: {hover}; }}
|
||||||
|
QPushButton:pressed {{ background: #222; }}
|
||||||
|
""",
|
||||||
|
"line_edit": """
|
||||||
|
QLineEdit {{
|
||||||
|
background: {bg_input}; color: white;
|
||||||
|
border: 2px solid {border}; border-radius: 8px;
|
||||||
|
padding: 6px;
|
||||||
|
}}
|
||||||
|
""",
|
||||||
|
"dialog": """
|
||||||
|
QDialog {{
|
||||||
|
background: {bg_dark}; color: white;
|
||||||
|
}}
|
||||||
|
""",
|
||||||
|
"tree_widget": """
|
||||||
|
QTreeWidget {{
|
||||||
|
background: {bg_input}; color: white;
|
||||||
|
border: 2px solid {border}; border-radius: 8px;
|
||||||
|
}}
|
||||||
|
QTreeWidget::item {{
|
||||||
|
padding: 8px 4px; border-bottom: 1px solid #1a2f45;
|
||||||
|
}}
|
||||||
|
QTreeWidget::item:selected {{
|
||||||
|
background: {border};
|
||||||
|
}}
|
||||||
|
QTreeWidget::branch {{
|
||||||
|
background: {bg_input};
|
||||||
|
}}
|
||||||
|
""",
|
||||||
|
"bookmark_panel": """
|
||||||
|
QWidget {{
|
||||||
|
background: {toolbar};
|
||||||
|
}}
|
||||||
|
QPushButton {{
|
||||||
|
background: transparent; color: white; border: none;
|
||||||
|
text-align: left; padding: 8px 20px;
|
||||||
|
font-size: 17px; border-radius: 6px;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background: {border}; }}
|
||||||
|
QPushButton:pressed {{ background: #222; }}
|
||||||
|
QLabel {{
|
||||||
|
color: {text_secondary}; padding: 6px 20px 2px 20px;
|
||||||
|
font-size: 14px; font-weight: bold;
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────
|
||||||
def mk_btn(label, color, hover, min_w=120, min_h=52, fs=FONT_SIZE):
|
def mk_btn(label, color, hover, min_w=120, min_h=52, fs=THEME["font_size"]):
|
||||||
b = QPushButton(label)
|
b = QPushButton(label)
|
||||||
b.setFont(QFont("Arial", fs, QFont.Bold))
|
b.setFont(QFont("Arial", fs, QFont.Bold))
|
||||||
b.setMinimumSize(min_w, min_h)
|
b.setMinimumSize(min_w, min_h)
|
||||||
b.setCursor(Qt.PointingHandCursor)
|
b.setCursor(Qt.PointingHandCursor)
|
||||||
b.setStyleSheet(f"""
|
b.setStyleSheet(STYLES["button"].format(color=color, hover=hover))
|
||||||
QPushButton {{ background:{color}; color:white; border:none;
|
|
||||||
border-radius:10px; padding:6px 14px; }}
|
|
||||||
QPushButton:hover {{ background:{hover}; }}
|
|
||||||
QPushButton:pressed {{ background:#222; }}
|
|
||||||
QPushButton:disabled {{ background:#444; color:#888; }}
|
|
||||||
""")
|
|
||||||
return b
|
return b
|
||||||
|
|
||||||
def mk_icon_btn(icon, color, hover, size=58, fs=26):
|
def mk_icon_btn(icon, color, hover, size=58, fs=26):
|
||||||
"""Square icon button for Back / Forward / Home / Hamburger."""
|
|
||||||
b = QPushButton(icon)
|
b = QPushButton(icon)
|
||||||
b.setFont(QFont("Arial", fs))
|
b.setFont(QFont("Arial", fs))
|
||||||
b.setFixedSize(size, size)
|
b.setFixedSize(size, size)
|
||||||
b.setCursor(Qt.PointingHandCursor)
|
b.setCursor(Qt.PointingHandCursor)
|
||||||
b.setStyleSheet(f"""
|
b.setStyleSheet(STYLES["icon_button"].format(color=color, hover=hover))
|
||||||
QPushButton {{ background:{color}; color:white; border:none;
|
|
||||||
border-radius:12px; }}
|
|
||||||
QPushButton:hover {{ background:{hover}; }}
|
|
||||||
QPushButton:pressed {{ background:#222; }}
|
|
||||||
""")
|
|
||||||
return b
|
return b
|
||||||
|
|
||||||
def load_bm():
|
def load_bm():
|
||||||
@@ -80,87 +137,116 @@ def load_bm():
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def save_bm(data):
|
def save_bm(data):
|
||||||
with open(BOOKMARKS_FILE, "w") as f: json.dump(data, f, indent=2)
|
with open(BOOKMARKS_FILE, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
def is_folder(item): return "children" in item
|
def is_folder(item):
|
||||||
|
return "children" in item
|
||||||
|
|
||||||
|
|
||||||
# ── CDP controller ────────────────────────────────────────────────
|
# ── CDP controller (Persistent WebSockets) ────────────────────────
|
||||||
class CDP:
|
class CDP:
|
||||||
"""Thin Chrome DevTools Protocol client — navigate, back, forward."""
|
"""Thin, persistent Chrome DevTools Protocol client."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.ready = False
|
self.ready = False
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self.ws = None
|
||||||
|
self.ws_url = None
|
||||||
|
|
||||||
def wait_ready(self, timeout=30):
|
def wait_ready(self, timeout=30):
|
||||||
for _ in range(timeout * 2):
|
for _ in range(timeout * 2):
|
||||||
try:
|
try:
|
||||||
urllib.request.urlopen(
|
urllib.request.urlopen(f"http://127.0.0.1:{CDP_PORT}/json", timeout=1)
|
||||||
f"http://127.0.0.1:{CDP_PORT}/json", timeout=1)
|
|
||||||
self.ready = True
|
self.ready = True
|
||||||
print("CDP ready")
|
print("CDP ready — pre-connecting WebSocket")
|
||||||
|
self._connect()
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
print("CDP not available after timeout")
|
print("CDP not available after timeout")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _ws_url(self):
|
def _connect(self):
|
||||||
raw = urllib.request.urlopen(
|
"""Internal helper to connect or reconnect the persistent WS client."""
|
||||||
f"http://127.0.0.1:{CDP_PORT}/json", timeout=2).read()
|
with self._lock:
|
||||||
tabs = json.loads(raw)
|
if self.ws:
|
||||||
page = next((t for t in tabs if t.get("type") == "page"), None)
|
try:
|
||||||
return page["webSocketDebuggerUrl"].replace("localhost", "127.0.0.1") if page else None
|
self.ws.ping()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
self._close_ws()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch WebSocket endpoint once, or re-fetch on disconnect
|
||||||
|
raw = urllib.request.urlopen(f"http://127.0.0.1:{CDP_PORT}/json", timeout=2).read()
|
||||||
|
tabs = json.loads(raw)
|
||||||
|
page = next((t for t in tabs if t.get("type") == "page"), None)
|
||||||
|
if page:
|
||||||
|
self.ws_url = page["webSocketDebuggerUrl"].replace("localhost", "127.0.0.1")
|
||||||
|
self.ws = websocket.create_connection(self.ws_url, timeout=3)
|
||||||
|
print(f"Connected persistent WebSocket to: {self.ws_url}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"CDP connection failed: {e}")
|
||||||
|
self._close_ws()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _close_ws(self):
|
||||||
|
if self.ws:
|
||||||
|
try:
|
||||||
|
self.ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.ws = None
|
||||||
|
|
||||||
def _send(self, method, params=None):
|
def _send(self, method, params=None):
|
||||||
if not self.ready:
|
if not self.ready:
|
||||||
return
|
return
|
||||||
|
if not self.ws and not self._connect():
|
||||||
|
return
|
||||||
with self._lock:
|
with self._lock:
|
||||||
try:
|
try:
|
||||||
ws = websocket.create_connection(self._ws_url(), timeout=3)
|
self.ws.send(json.dumps({"id": 1, "method": method,
|
||||||
ws.send(json.dumps({"id": 1, "method": method,
|
|
||||||
**( {"params": params} if params else {})}))
|
**( {"params": params} if params else {})}))
|
||||||
ws.recv()
|
self.ws.recv()
|
||||||
ws.close()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"CDP error ({method}): {e}")
|
print(f"CDP send error ({method}): {e}")
|
||||||
|
self._close_ws()
|
||||||
|
|
||||||
def _async(self, method, params=None):
|
def _async(self, method, params=None):
|
||||||
threading.Thread(target=self._send, args=(method, params),
|
threading.Thread(target=self._send, args=(method, params), daemon=True).start()
|
||||||
daemon=True).start()
|
|
||||||
|
|
||||||
def resize_window(self, x, y, w, h):
|
def resize_window(self, x, y, w, h):
|
||||||
"""Move and resize the Chrome window via CDP Browser.setWindowBounds."""
|
"""Move and resize the Chrome window via CDP Browser.setWindowBounds."""
|
||||||
if not self.ready:
|
if not self.ready:
|
||||||
return
|
return
|
||||||
|
if not self.ws and not self._connect():
|
||||||
|
return
|
||||||
with self._lock:
|
with self._lock:
|
||||||
try:
|
try:
|
||||||
ws = websocket.create_connection(self._ws_url(), timeout=3)
|
self.ws.send(json.dumps({"id": 1, "method": "Browser.getWindowForTarget"}))
|
||||||
ws.send(json.dumps({"id": 1, "method": "Browser.getWindowForTarget"}))
|
resp = json.loads(self.ws.recv())
|
||||||
resp = json.loads(ws.recv())
|
|
||||||
wid = resp.get("result", {}).get("windowId")
|
wid = resp.get("result", {}).get("windowId")
|
||||||
if wid:
|
if wid:
|
||||||
ws.send(json.dumps({"id": 2,
|
self.ws.send(json.dumps({"id": 2,
|
||||||
"method": "Browser.setWindowBounds",
|
"method": "Browser.setWindowBounds",
|
||||||
"params": {"windowId": wid,
|
"params": {"windowId": wid,
|
||||||
"bounds": {"left": x, "top": y,
|
"bounds": {"left": x, "top": y,
|
||||||
"width": w, "height": h,
|
"width": w, "height": h,
|
||||||
"windowState": "normal"}}}))
|
"windowState": "normal"}}}))
|
||||||
ws.recv()
|
self.ws.recv()
|
||||||
ws.close()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"CDP resize error: {e}")
|
print(f"CDP resize error: {e}")
|
||||||
|
self._close_ws()
|
||||||
|
|
||||||
def navigate(self, url):
|
def navigate(self, url):
|
||||||
self._async("Page.navigate", {"url": url})
|
self._async("Page.navigate", {"url": url})
|
||||||
|
|
||||||
def back(self):
|
def back(self):
|
||||||
self._async("Runtime.evaluate",
|
self._async("Runtime.evaluate", {"expression": "window.history.back()"})
|
||||||
{"expression": "window.history.back()"})
|
|
||||||
|
|
||||||
def forward(self):
|
def forward(self):
|
||||||
self._async("Runtime.evaluate",
|
self._async("Runtime.evaluate", {"expression": "window.history.forward()"})
|
||||||
{"expression": "window.history.forward()"})
|
|
||||||
|
|
||||||
|
|
||||||
# ── Folder name dialog ────────────────────────────────────────────
|
# ── Folder name dialog ────────────────────────────────────────────
|
||||||
@@ -168,23 +254,28 @@ def ask_name(parent, title="Name", current=""):
|
|||||||
d = QDialog(parent)
|
d = QDialog(parent)
|
||||||
d.setWindowTitle(title)
|
d.setWindowTitle(title)
|
||||||
d.setMinimumWidth(480)
|
d.setMinimumWidth(480)
|
||||||
d.setStyleSheet("background:#1e2a3a; color:white;")
|
d.setStyleSheet(STYLES["dialog"].format(bg_dark=THEME["colors"]["bg_dark"]))
|
||||||
lay = QVBoxLayout(d)
|
lay = QVBoxLayout(d)
|
||||||
lay.setContentsMargins(28, 28, 28, 28)
|
lay.setContentsMargins(28, 28, 28, 28)
|
||||||
lay.setSpacing(16)
|
lay.setSpacing(16)
|
||||||
|
|
||||||
lbl = QLabel("Folder name:")
|
lbl = QLabel("Folder name:")
|
||||||
lbl.setFont(QFont("Arial", 16))
|
lbl.setFont(QFont("Arial", 16))
|
||||||
lbl.setStyleSheet("color:#aac8e8;")
|
lbl.setStyleSheet(f"color:{THEME['colors']['text_secondary']};")
|
||||||
lay.addWidget(lbl)
|
lay.addWidget(lbl)
|
||||||
|
|
||||||
edit = QLineEdit(current)
|
edit = QLineEdit(current)
|
||||||
edit.setFont(QFont("Arial", 18))
|
edit.setFont(QFont("Arial", 18))
|
||||||
edit.setMinimumHeight(50)
|
edit.setMinimumHeight(50)
|
||||||
edit.setStyleSheet("background:#2a3f55;color:white;border:2px solid #3a6da4;"
|
edit.setStyleSheet(STYLES["line_edit"].format(
|
||||||
"border-radius:8px;padding:6px;")
|
bg_input=THEME["colors"]["bg_input"],
|
||||||
|
border=THEME["colors"]["border"]
|
||||||
|
))
|
||||||
lay.addWidget(edit)
|
lay.addWidget(edit)
|
||||||
|
|
||||||
row = QHBoxLayout()
|
row = QHBoxLayout()
|
||||||
ok = mk_btn("💾 Save", GREEN, GREEN_HOVER, 150, 56, 16)
|
ok = mk_btn("💾 Save", THEME["colors"]["green"], THEME["colors"]["green_hover"], 150, 56, 16)
|
||||||
can = mk_btn("✖ Cancel", RED, RED_HOVER, 150, 56, 16)
|
can = mk_btn("✖ Cancel", THEME["colors"]["red"], THEME["colors"]["red_hover"], 150, 56, 16)
|
||||||
ok.clicked.connect(d.accept); can.clicked.connect(d.reject)
|
ok.clicked.connect(d.accept); can.clicked.connect(d.reject)
|
||||||
row.addStretch(); row.addWidget(ok); row.addWidget(can)
|
row.addStretch(); row.addWidget(ok); row.addWidget(can)
|
||||||
lay.addLayout(row)
|
lay.addLayout(row)
|
||||||
@@ -197,22 +288,28 @@ class BookmarkDialog(QDialog):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Bookmark")
|
self.setWindowTitle("Bookmark")
|
||||||
self.setMinimumWidth(560)
|
self.setMinimumWidth(560)
|
||||||
self.setStyleSheet("background:#1e2a3a; color:white;")
|
self.setStyleSheet(STYLES["dialog"].format(bg_dark=THEME["colors"]["bg_dark"]))
|
||||||
lay = QVBoxLayout(self)
|
lay = QVBoxLayout(self)
|
||||||
lay.setSpacing(18); lay.setContentsMargins(30, 30, 30, 30)
|
lay.setSpacing(18); lay.setContentsMargins(30, 30, 30, 30)
|
||||||
for lbl_text, attr, val in [("Name:", "name_edit", name),
|
|
||||||
("URL:", "url_edit", url)]:
|
for lbl_text, attr, val in [("Name:", "name_edit", name), ("URL:", "url_edit", url)]:
|
||||||
l = QLabel(lbl_text)
|
l = QLabel(lbl_text)
|
||||||
l.setFont(QFont("Arial", 16)); l.setStyleSheet("color:#aac8e8;")
|
l.setFont(QFont("Arial", 16))
|
||||||
|
l.setStyleSheet(f"color:{THEME['colors']['text_secondary']};")
|
||||||
lay.addWidget(l)
|
lay.addWidget(l)
|
||||||
|
|
||||||
e = QLineEdit(val)
|
e = QLineEdit(val)
|
||||||
e.setFont(QFont("Arial", 18)); e.setMinimumHeight(50)
|
e.setFont(QFont("Arial", 18)); e.setMinimumHeight(50)
|
||||||
e.setStyleSheet("background:#2a3f55;color:white;border:2px solid #3a6da4;"
|
e.setStyleSheet(STYLES["line_edit"].format(
|
||||||
"border-radius:8px;padding:6px;")
|
bg_input=THEME["colors"]["bg_input"],
|
||||||
lay.addWidget(e); setattr(self, attr, e)
|
border=THEME["colors"]["border"]
|
||||||
|
))
|
||||||
|
lay.addWidget(e)
|
||||||
|
setattr(self, attr, e)
|
||||||
|
|
||||||
row = QHBoxLayout()
|
row = QHBoxLayout()
|
||||||
ok = mk_btn("💾 Save", GREEN, GREEN_HOVER, 160, 58, 16)
|
ok = mk_btn("💾 Save", THEME["colors"]["green"], THEME["colors"]["green_hover"], 160, 58, 16)
|
||||||
can = mk_btn("✖ Cancel", RED, RED_HOVER, 160, 58, 16)
|
can = mk_btn("✖ Cancel", THEME["colors"]["red"], THEME["colors"]["red_hover"], 160, 58, 16)
|
||||||
ok.clicked.connect(self.accept); can.clicked.connect(self.reject)
|
ok.clicked.connect(self.accept); can.clicked.connect(self.reject)
|
||||||
row.addStretch(); row.addWidget(ok); row.addWidget(can)
|
row.addStretch(); row.addWidget(ok); row.addWidget(can)
|
||||||
lay.addLayout(row)
|
lay.addLayout(row)
|
||||||
@@ -227,7 +324,7 @@ class ManagerDialog(QDialog):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Manage Bookmarks")
|
self.setWindowTitle("Manage Bookmarks")
|
||||||
self.setMinimumSize(860, 640)
|
self.setMinimumSize(860, 640)
|
||||||
self.setStyleSheet("background:#1e2a3a; color:white;")
|
self.setStyleSheet(STYLES["dialog"].format(bg_dark=THEME["colors"]["bg_dark"]))
|
||||||
lay = QVBoxLayout(self)
|
lay = QVBoxLayout(self)
|
||||||
lay.setContentsMargins(20, 20, 20, 20); lay.setSpacing(12)
|
lay.setContentsMargins(20, 20, 20, 20); lay.setSpacing(12)
|
||||||
|
|
||||||
@@ -239,35 +336,36 @@ class ManagerDialog(QDialog):
|
|||||||
hint = QLabel("Select a folder to add inside it, or nothing to add at top level. "
|
hint = QLabel("Select a folder to add inside it, or nothing to add at top level. "
|
||||||
"▲ ▼ move items within the same folder.")
|
"▲ ▼ move items within the same folder.")
|
||||||
hint.setFont(QFont("Arial", 13))
|
hint.setFont(QFont("Arial", 13))
|
||||||
hint.setStyleSheet("color:#88aacc;"); hint.setWordWrap(True)
|
hint.setStyleSheet(f"color:{THEME['colors']['text_secondary']};"); hint.setWordWrap(True)
|
||||||
lay.addWidget(hint)
|
lay.addWidget(hint)
|
||||||
|
|
||||||
self.tree = QTreeWidget()
|
self.tree = QTreeWidget()
|
||||||
self.tree.setHeaderHidden(True)
|
self.tree.setHeaderHidden(True)
|
||||||
self.tree.setFont(QFont("Arial", 15))
|
self.tree.setFont(QFont("Arial", 15))
|
||||||
self.tree.setSelectionMode(QAbstractItemView.SingleSelection)
|
self.tree.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||||
self.tree.setStyleSheet("""
|
self.tree.setStyleSheet(STYLES["tree_widget"].format(
|
||||||
QTreeWidget { background:#2a3f55; color:white;
|
bg_input=THEME["colors"]["bg_input"],
|
||||||
border:2px solid #3a6da4; border-radius:8px; }
|
border=THEME["colors"]["border"]
|
||||||
QTreeWidget::item { padding:8px 4px; border-bottom:1px solid #1a2f45; }
|
))
|
||||||
QTreeWidget::item:selected { background:#3a6da4; }
|
|
||||||
QTreeWidget::branch { background:#2a3f55; }
|
|
||||||
""")
|
|
||||||
self.tree.setIndentation(30)
|
self.tree.setIndentation(30)
|
||||||
self._reload_tree()
|
self._reload_tree()
|
||||||
lay.addWidget(self.tree)
|
lay.addWidget(self.tree)
|
||||||
|
|
||||||
row = QHBoxLayout(); row.setSpacing(8)
|
row = QHBoxLayout(); row.setSpacing(8)
|
||||||
btns = [mk_btn("➕ Bookmark", GREEN, GREEN_HOVER, 155, 54, 14),
|
btns = [
|
||||||
mk_btn("📁 Folder", AMBER, AMBER_HOVER, 130, 54, 14),
|
mk_btn("➕ Bookmark", THEME["colors"]["green"], THEME["colors"]["green_hover"], 155, 54, 14),
|
||||||
mk_btn("✏️ Edit", BTN_COLOR,BTN_HOVER, 120, 54, 14),
|
mk_btn("📁 Folder", THEME["colors"]["amber"], THEME["colors"]["amber_hover"], 130, 54, 14),
|
||||||
mk_btn("🗑 Delete", RED, RED_HOVER, 120, 54, 14),
|
mk_btn("✏️ Edit", THEME["colors"]["btn"], THEME["colors"]["btn_hover"], 120, 54, 14),
|
||||||
mk_btn("▲ Up", PURPLE, PURPLE_HOVER, 90, 54, 14),
|
mk_btn("🗑 Delete", THEME["colors"]["red"], THEME["colors"]["red_hover"], 120, 54, 14),
|
||||||
mk_btn("▼ Down", PURPLE, PURPLE_HOVER, 90, 54, 14),
|
mk_btn("▲ Up", THEME["colors"]["purple"], THEME["colors"]["purple_hover"], 90, 54, 14),
|
||||||
mk_btn("✖ Close", "#555", "#777", 120, 54, 14)]
|
mk_btn("▼ Down", THEME["colors"]["purple"], THEME["colors"]["purple_hover"], 90, 54, 14),
|
||||||
acts = [self.add_bookmark, self.add_folder, self.edit_item,
|
mk_btn("✖ Close", "#555", "#777", 120, 54, 14)
|
||||||
self.delete_item, lambda: self.move_item(-1),
|
]
|
||||||
lambda: self.move_item(1), self.accept]
|
acts = [
|
||||||
|
self.add_bookmark, self.add_folder, self.edit_item,
|
||||||
|
self.delete_item, lambda: self.move_item(-1),
|
||||||
|
lambda: self.move_item(1), self.accept
|
||||||
|
]
|
||||||
for b, a in zip(btns, acts):
|
for b, a in zip(btns, acts):
|
||||||
b.clicked.connect(a); row.addWidget(b)
|
b.clicked.connect(a); row.addWidget(b)
|
||||||
lay.addLayout(row)
|
lay.addLayout(row)
|
||||||
@@ -303,7 +401,8 @@ class ManagerDialog(QDialog):
|
|||||||
items = self.tree.selectedItems()
|
items = self.tree.selectedItems()
|
||||||
return items[0] if items else None
|
return items[0] if items else None
|
||||||
|
|
||||||
def _commit(self): save_bm(self._to_list())
|
def _commit(self):
|
||||||
|
save_bm(self._to_list())
|
||||||
|
|
||||||
def _insert(self, node):
|
def _insert(self, node):
|
||||||
sel = self._sel()
|
sel = self._sel()
|
||||||
@@ -357,12 +456,11 @@ class ManagerDialog(QDialog):
|
|||||||
if not sel: return
|
if not sel: return
|
||||||
d = sel.data(0, Qt.UserRole)
|
d = sel.data(0, Qt.UserRole)
|
||||||
fld = "_folder" in d
|
fld = "_folder" in d
|
||||||
msg = (f"Delete folder '{d['name']}' and ALL its bookmarks?"
|
msg = f"Delete folder '{d['name']}' and ALL its bookmarks?" if fld else f"Delete '{d['name']}'?"
|
||||||
if fld else f"Delete '{d['name']}'?")
|
|
||||||
box = QMessageBox(self)
|
box = QMessageBox(self)
|
||||||
box.setWindowTitle("Delete"); box.setText(msg)
|
box.setWindowTitle("Delete"); box.setText(msg)
|
||||||
box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
|
box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
|
||||||
box.setStyleSheet("background:#1e2a3a; color:white; font-size:17px;")
|
box.setStyleSheet(f"background:{THEME['colors']['bg_dark']}; color:white; font-size:17px;")
|
||||||
if box.exec_() == QMessageBox.Yes:
|
if box.exec_() == QMessageBox.Yes:
|
||||||
par = sel.parent() or self.tree.invisibleRootItem()
|
par = sel.parent() or self.tree.invisibleRootItem()
|
||||||
par.removeChild(sel); self._commit()
|
par.removeChild(sel); self._commit()
|
||||||
@@ -377,77 +475,24 @@ class ManagerDialog(QDialog):
|
|||||||
self.tree.setCurrentItem(sel); self._commit()
|
self.tree.setCurrentItem(sel); self._commit()
|
||||||
|
|
||||||
|
|
||||||
# ── X11 helpers (clicks/focus on Chromium never reach Qt) ───────────
|
# ── Bookmark dropdown panel (Native Focus-Loss Dismissal) ─────────
|
||||||
def _x11_env():
|
|
||||||
env = os.environ.copy()
|
|
||||||
env.setdefault("DISPLAY", ":0")
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
def _x11_run(cmd, timeout=0.4):
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
cmd, capture_output=True, text=True,
|
|
||||||
timeout=timeout, env=_x11_env())
|
|
||||||
return r.stdout.strip() if r.returncode == 0 else ""
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _x11_active_window_id():
|
|
||||||
out = _x11_run(["xdotool", "getactivewindow"])
|
|
||||||
return int(out) if out.isdigit() else 0
|
|
||||||
|
|
||||||
|
|
||||||
def _x11_left_button_down():
|
|
||||||
"""True when the physical left mouse button is held (works across all apps)."""
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
["bash", "-c",
|
|
||||||
"DISPLAY=:0 dev=$(xinput --list --short 2>/dev/null | "
|
|
||||||
"awk '/slave.*pointer/ {print $2; exit}'); "
|
|
||||||
"[ -n \"$dev\" ] && xinput query-state \"$dev\" 2>/dev/null | "
|
|
||||||
"grep -q 'button\\[1\\]=down'"],
|
|
||||||
timeout=0.3, env=_x11_env())
|
|
||||||
return r.returncode == 0
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ── Bookmark dropdown panel ───────────────────────────────────────
|
|
||||||
class BookmarkPanel(QWidget):
|
class BookmarkPanel(QWidget):
|
||||||
"""
|
"""
|
||||||
Custom dropdown that replaces QMenu for bookmarks.
|
Custom dropdown that replaces QMenu for bookmarks.
|
||||||
Uses the same Qt.Tool | WindowStaysOnTopHint flags as the toolbar so it
|
Uses Qt.Popup flag for native focus-out dismissal on outside click (0% CPU).
|
||||||
always appears above Chrome, unlike QMenu which has z-order issues.
|
|
||||||
"""
|
"""
|
||||||
_OPEN_GRACE_SEC = 0.35
|
|
||||||
|
|
||||||
def __init__(self, cdp, overlay):
|
def __init__(self, cdp, overlay):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.cdp = cdp
|
self.cdp = cdp
|
||||||
self.overlay = overlay
|
self.overlay = overlay
|
||||||
self._shown_at = 0.0
|
self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||||||
self._focus_at_open = 0
|
self.setStyleSheet(STYLES["bookmark_panel"].format(
|
||||||
self._our_window_ids = set()
|
toolbar=THEME["colors"]["toolbar"],
|
||||||
self.setWindowFlags(
|
border=THEME["colors"]["border"],
|
||||||
Qt.Tool | Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint)
|
text_secondary=THEME["colors"]["text_secondary"]
|
||||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
))
|
||||||
self.setStyleSheet(f"""
|
|
||||||
QWidget {{ background: #1e3a5c; }}
|
|
||||||
QPushButton {{ background: transparent; color: white; border: none;
|
|
||||||
text-align: left; padding: 8px 20px;
|
|
||||||
font-size: 17px; border-radius: 6px; }}
|
|
||||||
QPushButton:hover {{ background: #3a6da4; }}
|
|
||||||
QPushButton:pressed {{ background: #222; }}
|
|
||||||
QLabel {{ color: #aac8e8; padding: 6px 20px 2px 20px;
|
|
||||||
font-size: 14px; font-weight: bold; }}
|
|
||||||
""")
|
|
||||||
QApplication.instance().installEventFilter(self)
|
QApplication.instance().installEventFilter(self)
|
||||||
|
|
||||||
self._dismiss_timer = QTimer()
|
|
||||||
self._dismiss_timer.timeout.connect(self._check_dismiss)
|
|
||||||
|
|
||||||
def rebuild(self, items):
|
def rebuild(self, items):
|
||||||
# Remove old layout
|
# Remove old layout
|
||||||
old = self.layout()
|
old = self.layout()
|
||||||
@@ -459,7 +504,7 @@ class BookmarkPanel(QWidget):
|
|||||||
QWidget().setLayout(old)
|
QWidget().setLayout(old)
|
||||||
|
|
||||||
scroll_content = QWidget()
|
scroll_content = QWidget()
|
||||||
scroll_content.setStyleSheet("background: #1e3a5c;")
|
scroll_content.setStyleSheet(f"background: {THEME['colors']['toolbar']};")
|
||||||
vbox = QVBoxLayout(scroll_content)
|
vbox = QVBoxLayout(scroll_content)
|
||||||
vbox.setContentsMargins(6, 6, 6, 6)
|
vbox.setContentsMargins(6, 6, 6, 6)
|
||||||
vbox.setSpacing(1)
|
vbox.setSpacing(1)
|
||||||
@@ -468,7 +513,7 @@ class BookmarkPanel(QWidget):
|
|||||||
|
|
||||||
sep = QFrame()
|
sep = QFrame()
|
||||||
sep.setFrameShape(QFrame.HLine)
|
sep.setFrameShape(QFrame.HLine)
|
||||||
sep.setStyleSheet("background: #3a6da4; margin: 4px 8px;")
|
sep.setStyleSheet(f"background: {THEME['colors']['border']}; margin: 4px 8px;")
|
||||||
sep.setFixedHeight(1)
|
sep.setFixedHeight(1)
|
||||||
vbox.addWidget(sep)
|
vbox.addWidget(sep)
|
||||||
|
|
||||||
@@ -491,7 +536,7 @@ class BookmarkPanel(QWidget):
|
|||||||
outer.setSpacing(0)
|
outer.setSpacing(0)
|
||||||
outer.addWidget(scroll)
|
outer.addWidget(scroll)
|
||||||
|
|
||||||
# Size: cap height at 80 % of screen below toolbar
|
# Size: cap height at 80% of screen below toolbar
|
||||||
scroll_content.adjustSize()
|
scroll_content.adjustSize()
|
||||||
ph = scroll_content.sizeHint().height() + 16
|
ph = scroll_content.sizeHint().height() + 16
|
||||||
pw = max(scroll_content.sizeHint().width() + 16, 340)
|
pw = max(scroll_content.sizeHint().width() + 16, 340)
|
||||||
@@ -499,14 +544,6 @@ class BookmarkPanel(QWidget):
|
|||||||
self.resize(pw, min(ph, max_h))
|
self.resize(pw, min(ph, max_h))
|
||||||
self.move(SCREEN_W - pw - 4, TOOLBAR_H)
|
self.move(SCREEN_W - pw - 4, TOOLBAR_H)
|
||||||
|
|
||||||
def _refresh_our_window_ids(self):
|
|
||||||
ids = set()
|
|
||||||
for w in (self, self.overlay):
|
|
||||||
wid = int(w.winId())
|
|
||||||
if wid:
|
|
||||||
ids.add(wid)
|
|
||||||
self._our_window_ids = ids
|
|
||||||
|
|
||||||
def _burger_global_rect(self):
|
def _burger_global_rect(self):
|
||||||
btn = self.overlay.burger_btn
|
btn = self.overlay.burger_btn
|
||||||
origin = btn.mapToGlobal(QPoint(0, 0))
|
origin = btn.mapToGlobal(QPoint(0, 0))
|
||||||
@@ -537,51 +574,17 @@ class BookmarkPanel(QWidget):
|
|||||||
self.hide()
|
self.hide()
|
||||||
self.overlay.open_manager()
|
self.overlay.open_manager()
|
||||||
|
|
||||||
def hide(self):
|
def changeEvent(self, event):
|
||||||
self._dismiss_timer.stop()
|
"""Native X11 focus loss detector (when clicking Chromium outside the Qt app)."""
|
||||||
super().hide()
|
if event.type() == QEvent.ActivationChange:
|
||||||
|
if not self.isActiveWindow() and not self.overlay.isActiveWindow():
|
||||||
def setVisible(self, visible):
|
self.hide()
|
||||||
super().setVisible(visible)
|
super().changeEvent(event)
|
||||||
if visible:
|
|
||||||
self._shown_at = time.time()
|
|
||||||
self._refresh_our_window_ids()
|
|
||||||
self._focus_at_open = _x11_active_window_id()
|
|
||||||
self.raise_()
|
|
||||||
self.overlay.raise_()
|
|
||||||
self._dismiss_timer.start(100)
|
|
||||||
else:
|
|
||||||
self._dismiss_timer.stop()
|
|
||||||
|
|
||||||
def _cursor_outside_panel(self):
|
|
||||||
pos = QCursor.pos()
|
|
||||||
if self.frameGeometry().contains(pos):
|
|
||||||
return False
|
|
||||||
if self._burger_global_rect().contains(pos):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _check_dismiss(self):
|
|
||||||
"""Close when focus leaves or user clicks outside the panel."""
|
|
||||||
if not self.isVisible():
|
|
||||||
self._dismiss_timer.stop()
|
|
||||||
return
|
|
||||||
if time.time() - self._shown_at < self._OPEN_GRACE_SEC:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Click outside (X11 — works even when Chromium has focus)
|
|
||||||
if _x11_left_button_down() and self._cursor_outside_panel():
|
|
||||||
self.hide()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Focus moved away from toolbar / menu (e.g. clicked the web page)
|
|
||||||
active = _x11_active_window_id()
|
|
||||||
if active and active != self._focus_at_open and active not in self._our_window_ids:
|
|
||||||
self.hide()
|
|
||||||
|
|
||||||
def eventFilter(self, obj, event):
|
def eventFilter(self, obj, event):
|
||||||
|
"""Native local clicks inside toolbar/panel dismissal logic."""
|
||||||
if event.type() == QEvent.MouseButtonPress and self.isVisible():
|
if event.type() == QEvent.MouseButtonPress and self.isVisible():
|
||||||
if not self.frameGeometry().contains(event.globalPos()):
|
if not self.geometry().contains(event.globalPos()) and not self._burger_global_rect().contains(event.globalPos()):
|
||||||
self.hide()
|
self.hide()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -591,11 +594,7 @@ class KioskOverlay(QWidget):
|
|||||||
def __init__(self, cdp):
|
def __init__(self, cdp):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.cdp = cdp
|
self.cdp = cdp
|
||||||
self.setWindowFlags(
|
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool)
|
||||||
Qt.FramelessWindowHint |
|
|
||||||
Qt.WindowStaysOnTopHint |
|
|
||||||
Qt.Tool
|
|
||||||
)
|
|
||||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||||
self.setGeometry(0, 0, SCREEN_W, TOOLBAR_H)
|
self.setGeometry(0, 0, SCREEN_W, TOOLBAR_H)
|
||||||
self.setStyleSheet("background:transparent;")
|
self.setStyleSheet("background:transparent;")
|
||||||
@@ -619,7 +618,6 @@ class KioskOverlay(QWidget):
|
|||||||
def _on_screen_resize(self, rect):
|
def _on_screen_resize(self, rect):
|
||||||
global SCREEN_W, SCREEN_H
|
global SCREEN_W, SCREEN_H
|
||||||
w, h = rect.width(), rect.height()
|
w, h = rect.width(), rect.height()
|
||||||
# Ignore bogus sizes from minimize / RDP disconnect events
|
|
||||||
if w < 400 or h < 200:
|
if w < 400 or h < 200:
|
||||||
return
|
return
|
||||||
SCREEN_W, SCREEN_H = w, h
|
SCREEN_W, SCREEN_H = w, h
|
||||||
@@ -628,7 +626,6 @@ class KioskOverlay(QWidget):
|
|||||||
if self.bm_panel.isVisible():
|
if self.bm_panel.isVisible():
|
||||||
pw = self.bm_panel.width()
|
pw = self.bm_panel.width()
|
||||||
self.bm_panel.move(SCREEN_W - pw - 4, TOOLBAR_H)
|
self.bm_panel.move(SCREEN_W - pw - 4, TOOLBAR_H)
|
||||||
# Resize Chrome after brief delay so X has settled
|
|
||||||
QTimer.singleShot(500, self._resize_chrome)
|
QTimer.singleShot(500, self._resize_chrome)
|
||||||
|
|
||||||
def _resize_chrome(self):
|
def _resize_chrome(self):
|
||||||
@@ -670,7 +667,7 @@ class KioskOverlay(QWidget):
|
|||||||
|
|
||||||
def _build_toolbar(self):
|
def _build_toolbar(self):
|
||||||
bar = QWidget(); bar.setFixedHeight(TOOLBAR_H)
|
bar = QWidget(); bar.setFixedHeight(TOOLBAR_H)
|
||||||
bar.setStyleSheet(f"background:{TOOLBAR_COLOR};")
|
bar.setStyleSheet(f"background:{THEME['colors']['toolbar']};")
|
||||||
tl = QHBoxLayout(bar)
|
tl = QHBoxLayout(bar)
|
||||||
tl.setContentsMargins(24, 7, 16, 7); tl.setSpacing(10)
|
tl.setContentsMargins(24, 7, 16, 7); tl.setSpacing(10)
|
||||||
|
|
||||||
@@ -692,25 +689,25 @@ class KioskOverlay(QWidget):
|
|||||||
tl.addStretch()
|
tl.addStretch()
|
||||||
|
|
||||||
# ◀ Back
|
# ◀ Back
|
||||||
back_btn = mk_icon_btn("◀", BTN_COLOR, BTN_HOVER)
|
back_btn = mk_icon_btn("◀", THEME["colors"]["btn"], THEME["colors"]["btn_hover"])
|
||||||
back_btn.setToolTip("Go Back")
|
back_btn.setToolTip("Go Back")
|
||||||
back_btn.clicked.connect(self.cdp.back)
|
back_btn.clicked.connect(self.cdp.back)
|
||||||
tl.addWidget(back_btn)
|
tl.addWidget(back_btn)
|
||||||
|
|
||||||
# ▶ Forward
|
# ▶ Forward
|
||||||
fwd_btn = mk_icon_btn("▶", BTN_COLOR, BTN_HOVER)
|
fwd_btn = mk_icon_btn("▶", THEME["colors"]["btn"], THEME["colors"]["btn_hover"])
|
||||||
fwd_btn.setToolTip("Go Forward")
|
fwd_btn.setToolTip("Go Forward")
|
||||||
fwd_btn.clicked.connect(self.cdp.forward)
|
fwd_btn.clicked.connect(self.cdp.forward)
|
||||||
tl.addWidget(fwd_btn)
|
tl.addWidget(fwd_btn)
|
||||||
|
|
||||||
# ⌂ Home
|
# ⌂ Home
|
||||||
home_btn = mk_icon_btn("⌂", GOLD, GOLD_HOVER)
|
home_btn = mk_icon_btn("⌂", THEME["colors"]["gold"], THEME["colors"]["gold_hover"])
|
||||||
home_btn.setToolTip("Go Home")
|
home_btn.setToolTip("Go Home")
|
||||||
home_btn.clicked.connect(lambda: self.cdp.navigate(HOME_URL))
|
home_btn.clicked.connect(lambda: self.cdp.navigate(HOME_URL))
|
||||||
tl.addWidget(home_btn)
|
tl.addWidget(home_btn)
|
||||||
|
|
||||||
# ☰ Hamburger — bookmarks menu
|
# ☰ Hamburger — bookmarks menu
|
||||||
self.burger_btn = mk_icon_btn("☰", PURPLE, PURPLE_HOVER)
|
self.burger_btn = mk_icon_btn("☰", THEME["colors"]["purple"], THEME["colors"]["purple_hover"])
|
||||||
self.burger_btn.setToolTip("Bookmarks")
|
self.burger_btn.setToolTip("Bookmarks")
|
||||||
self.burger_btn.clicked.connect(self._show_bookmarks_menu)
|
self.burger_btn.clicked.connect(self._show_bookmarks_menu)
|
||||||
tl.addWidget(self.burger_btn)
|
tl.addWidget(self.burger_btn)
|
||||||
@@ -786,8 +783,7 @@ def launch_chrome():
|
|||||||
_email_host = _email_url.split("//")[-1].split("/")[0] if _email_url else ""
|
_email_host = _email_url.split("//")[-1].split("/")[0] if _email_url else ""
|
||||||
conn = sqlite3.connect(str(cookies_db))
|
conn = sqlite3.connect(str(cookies_db))
|
||||||
if _email_host:
|
if _email_host:
|
||||||
conn.execute("DELETE FROM cookies WHERE host_key LIKE ?",
|
conn.execute("DELETE FROM cookies WHERE host_key LIKE ?", (f"%{_email_host}%",))
|
||||||
(f"%{_email_host}%",))
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
print(f"Cleared stale session cookie for {_email_host}")
|
print(f"Cleared stale session cookie for {_email_host}")
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -801,13 +797,13 @@ def launch_chrome():
|
|||||||
"--no-default-browser-check",
|
"--no-default-browser-check",
|
||||||
"--disable-restore-session-state",
|
"--disable-restore-session-state",
|
||||||
"--disable-default-browser-check",
|
"--disable-default-browser-check",
|
||||||
"--disable-features=TranslateUI,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationGuideModelDownloading",
|
"--disable-features=Translate,TranslateUI,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationGuideModelDownloading",
|
||||||
"--disable-component-update",
|
"--disable-component-update",
|
||||||
"--disable-background-networking",
|
"--disable-background-networking",
|
||||||
"--disable-extensions",
|
"--disable-extensions",
|
||||||
"--disable-gpu",
|
"--disable-gpu",
|
||||||
"--disable-dev-shm-usage",
|
"--disable-dev-shm-usage",
|
||||||
f"--user-data-dir={_HOME / 'chromium-kiosk'}",
|
f"--user-data-dir={profile_dir}",
|
||||||
f"--remote-debugging-port={CDP_PORT}",
|
f"--remote-debugging-port={CDP_PORT}",
|
||||||
"--remote-allow-origins=*",
|
"--remote-allow-origins=*",
|
||||||
f"--window-position=0,{TOOLBAR_H}",
|
f"--window-position=0,{TOOLBAR_H}",
|
||||||
@@ -820,39 +816,18 @@ def launch_chrome():
|
|||||||
# ── Main ──────────────────────────────────────────────────────────
|
# ── Main ──────────────────────────────────────────────────────────
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
# Detect actual screen resolution at runtime
|
|
||||||
_geo = app.primaryScreen().geometry()
|
_geo = app.primaryScreen().geometry()
|
||||||
SCREEN_W, SCREEN_H = _geo.width(), _geo.height()
|
SCREEN_W, SCREEN_H = _geo.width(), _geo.height()
|
||||||
app.setApplicationName("SeniorNet")
|
app.setApplicationName("SeniorNet")
|
||||||
p = app.palette(); p.setColor(QPalette.Window, QColor("#1a3a5c")); app.setPalette(p)
|
p = app.palette(); p.setColor(QPalette.Window, QColor(THEME["colors"]["toolbar"])); app.setPalette(p)
|
||||||
|
|
||||||
cdp = CDP()
|
cdp = CDP()
|
||||||
overlay = KioskOverlay(cdp)
|
overlay = KioskOverlay(cdp)
|
||||||
|
|
||||||
chrome_proc = launch_chrome()
|
chrome_proc = launch_chrome()
|
||||||
|
|
||||||
def dismiss_chrome_dialogs():
|
|
||||||
"""
|
|
||||||
Chrome shows startup dialogs (first-run welcome, profile errors) on a
|
|
||||||
fresh or corrupted profile. Dismiss them all automatically so the
|
|
||||||
senior never sees them. Runs for ~15 seconds after startup.
|
|
||||||
"""
|
|
||||||
dialogs = [
|
|
||||||
"Welcome to Google Chrome",
|
|
||||||
"Profile error occurred",
|
|
||||||
]
|
|
||||||
for _ in range(15):
|
|
||||||
time.sleep(1)
|
|
||||||
for title in dialogs:
|
|
||||||
subprocess.run(
|
|
||||||
["xdotool", "search", "--name", title, "key", "Return"],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
||||||
)
|
|
||||||
|
|
||||||
def init_cdp():
|
def init_cdp():
|
||||||
if cdp.wait_ready(timeout=30):
|
if cdp.wait_ready(timeout=30):
|
||||||
print("CDP ready — kiosk running")
|
print("CDP ready — persistent WebSocket connected successfully")
|
||||||
dismiss_chrome_dialogs()
|
|
||||||
threading.Thread(target=init_cdp, daemon=True).start()
|
threading.Thread(target=init_cdp, daemon=True).start()
|
||||||
|
|
||||||
sys.exit(app.exec_())
|
sys.exit(app.exec_())
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"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": "https://news.google.com",
|
||||||
"facebook": "https://www.facebook.com",
|
"facebook": "https://www.facebook.com",
|
||||||
"messenger": "https://www.messenger.com",
|
"messenger": "https://www.messenger.com",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ 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"
|
||||||
CONFIG_SRC="$REPO_DIR/config.json"
|
CONFIG_SRC="$REPO_DIR/config.json"
|
||||||
# Auto-increment patch version (0.2.0 → 0.2.1 → 0.2.2 ...)
|
# Auto-increment patch version (0.2.0 → 0.2.1 → 0.2.2 ...)
|
||||||
VERSION_FILE="$REPO_DIR/VERSION"
|
VERSION_FILE="$REPO_DIR/VERSION"
|
||||||
@@ -54,6 +56,12 @@ 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
|
||||||
|
|
||||||
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)"
|
||||||
@@ -61,7 +69,11 @@ git -C "$REPO_DIR" remote set-url origin "http://$GITEA_USER:$GITEA_TOKEN@192.16
|
|||||||
git -C "$REPO_DIR" push origin master || echo " (push failed — check network)"
|
git -C "$REPO_DIR" push origin master || echo " (push failed — check network)"
|
||||||
|
|
||||||
echo "==> Restarting kiosk-dev..."
|
echo "==> Restarting kiosk-dev..."
|
||||||
/usr/local/bin/kiosk-restart
|
if [ -f "/usr/local/bin/kiosk-restart" ]; then
|
||||||
|
/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=""
|
||||||
@@ -80,8 +92,9 @@ 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
|
||||||
/usr/local/bin/kiosk-restart
|
/usr/local/bin/kiosk-restart
|
||||||
"
|
"
|
||||||
echo " Production updated."
|
echo " Production updated."
|
||||||
|
|||||||
+348
@@ -0,0 +1,348 @@
|
|||||||
|
<!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() {
|
||||||
|
// 1. Get coordinates using IP API fallback or direct Geolocation
|
||||||
|
try {
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
async (pos) => {
|
||||||
|
// Successfully got GPS coordinates
|
||||||
|
await fetchForecast(pos.coords.latitude, pos.coords.longitude, "Local Location");
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
// Denied or failed - fall back to IP lookup
|
||||||
|
await fetchViaIP();
|
||||||
|
},
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await fetchViaIP();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('loading-text').textContent = "⚠️ Unable to load weather. Check internet connection.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchViaIP() {
|
||||||
|
try {
|
||||||
|
document.getElementById('loading-text').textContent = "🌐 Looking up location via IP...";
|
||||||
|
const resp = await fetch("https://ipapi.co/json/");
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
let locName = "Your Area";
|
||||||
|
if (data.city && data.region) {
|
||||||
|
locName = `${data.city}, ${data.region}`;
|
||||||
|
} else if (data.city) {
|
||||||
|
locName = data.city;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.latitude && data.longitude) {
|
||||||
|
await fetchForecast(data.latitude, data.longitude, locName);
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid IP response coordinates");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ultimate hard-coded default (e.g. general center of US/state if offline)
|
||||||
|
document.getElementById('loading-text').textContent = "⚠️ Location lookup failed. Please verify internet.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchForecast(lat, lon, locationLabel) {
|
||||||
|
try {
|
||||||
|
document.getElementById('loading-text').textContent = "📊 Loading weather forecasts...";
|
||||||
|
|
||||||
|
// Query the completely open, keyless Open-Meteo API
|
||||||
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch&timezone=auto`;
|
||||||
|
|
||||||
|
const resp = await fetch(url);
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
// Render current stats
|
||||||
|
const current = data.current;
|
||||||
|
const wInfo = getWeatherInfo(current.weather_code);
|
||||||
|
|
||||||
|
document.getElementById('location-name').textContent = locationLabel;
|
||||||
|
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 = data.daily;
|
||||||
|
const forecastBox = document.getElementById('forecast-box');
|
||||||
|
forecastBox.innerHTML = ""; // Clear loader
|
||||||
|
|
||||||
|
// We render days 1, 2, and 3 (index 1 to 3, as index 0 is today)
|
||||||
|
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 = "⚠️ Error pulling forecast data.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onload = initWeather;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user