Files
WebChecker/views/user_dashboard_view.py
T

821 lines
35 KiB
Python

"""
views/user_dashboard_view.py — Regular user: Shift Dashboard.
New in this version:
- Live search/filter bar
- Bulk check-off (select all visible unchecked, then mark in one action)
- Shift-end notification via plyer (falls back to Tkinter toast)
"""
import tkinter as tk
from tkinter import ttk
import webbrowser
import logging
import threading
import datetime
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_SMALL,
scrolled_text, show_error, show_info,
)
logger = logging.getLogger("user_dashboard_view")
# How many minutes before shift end to fire a reminder notification
NOTIFY_MINUTES_BEFORE = 15
class UserDashboardView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self._all_sites = [] # full unfiltered list
self._site_check_vars = {} # website_id -> BooleanVar (bulk select)
self._notified_sites = set() # ids already notified this session
self._notify_job = None # after() handle
self._health_cache = {} # website_id -> ("ok"|"slow"|"down", ms)
self._build_ui()
self._load()
def destroy(self):
# Cancel notification timer
if self._notify_job:
try:
self.after_cancel(self._notify_job)
except Exception:
pass
# Remove mousewheel binding so it can't fire on a dead canvas
try:
self.canvas.unbind_all("<MouseWheel>")
except Exception:
pass
# Remove keyboard shortcut bindings
self._unbind_shortcuts()
# Destroy any open tooltip
self._hide_tooltip()
super().destroy()
# ─── Layout ───────────────────────────────────────────────────────────────
def _build_ui(self):
# ── Top bar ───────────────────────────────────────────────────────────
top = ttk.Frame(self)
top.pack(fill="x", pady=(0, 8))
ttk.Label(top, text="My Shift — Website Checklist",
style="Heading.TLabel").pack(side="left")
ttk.Label(
top,
text=f"{self.current_user['full_name'] or self.current_user['username']}",
style="Dim.TLabel"
).pack(side="right")
ttk.Button(top, text="↻ Refresh", style="Ghost.TButton",
command=self._load).pack(side="right", padx=(0, 8))
# ── Search + bulk actions bar ──────────────────────────────────────────
action_bar = tk.Frame(self, bg=COLOURS["surface"], pady=8)
action_bar.pack(fill="x", pady=(0, 8))
# Search box
tk.Label(action_bar, text="🔍", bg=COLOURS["surface"],
fg=COLOURS["text_dim"], font=FONT).pack(side="left", padx=(12, 4))
self._search_var = tk.StringVar()
self._search_var.trace_add("write", lambda *_: self._apply_filter())
search_ent = tk.Entry(
action_bar, textvariable=self._search_var, width=28,
bg=COLOURS["surface2"], fg=COLOURS["text"],
insertbackground=COLOURS["text"],
relief="flat", font=FONT
)
search_ent.pack(side="left", ipady=5, padx=(0, 16))
# Bulk action buttons
tk.Button(
action_bar, text="☑ Select All Unchecked",
command=self._select_all_unchecked,
bg=COLOURS["surface2"], fg=COLOURS["text"],
activebackground=COLOURS["accent"],
activeforeground=COLOURS["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
).pack(side="left", padx=(0, 6))
tk.Button(
action_bar, text="✔ Mark Selected Checked",
command=self._bulk_check,
bg=COLOURS["success"], fg=COLOURS["white"],
activebackground="#3d9140",
activeforeground=COLOURS["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
).pack(side="left")
# ── Progress bar ──────────────────────────────────────────────────────
prog_frame = ttk.Frame(self)
prog_frame.pack(fill="x", pady=(0, 6))
self.progress_label = ttk.Label(prog_frame, text="", style="Dim.TLabel")
self.progress_label.pack(side="left")
self.progress_bar = ttk.Progressbar(prog_frame, length=200,
mode="determinate")
self.progress_bar.pack(side="right")
ttk.Separator(self, orient="horizontal").pack(fill="x", pady=(0, 10))
# ── Scrollable sites list ─────────────────────────────────────────────
list_frame = ttk.Frame(self)
list_frame.pack(fill="both", expand=True)
self.canvas = tk.Canvas(list_frame, bg=COLOURS["bg"],
highlightthickness=0)
vsb = ttk.Scrollbar(list_frame, orient="vertical",
command=self.canvas.yview)
self.canvas.configure(yscrollcommand=vsb.set)
vsb.pack(side="right", fill="y")
self.canvas.pack(side="left", fill="both", expand=True)
self.sites_frame = ttk.Frame(self.canvas)
self.sites_frame.bind(
"<Configure>",
lambda e: self.canvas.configure(
scrollregion=self.canvas.bbox("all"))
)
self._canvas_window = self.canvas.create_window(
(0, 0), window=self.sites_frame, anchor="nw"
)
self.canvas.bind("<Configure>", self._on_canvas_resize)
# Scope mousewheel to the canvas and its children via Enter/Leave
self.canvas.bind("<Enter>", self._on_canvas_enter)
self.canvas.bind("<Leave>", self._on_canvas_leave)
self._mw_binding = None # track active bind_all handle
self._bind_shortcuts()
def _on_canvas_resize(self, event):
self.canvas.itemconfig(self._canvas_window, width=event.width)
def _on_canvas_enter(self, event):
"""Mouse entered canvas area — activate mousewheel scrolling."""
self._mw_binding = self.canvas.bind_all(
"<MouseWheel>", self._on_mousewheel
)
def _on_canvas_leave(self, event):
"""Mouse left canvas area — deactivate mousewheel scrolling."""
try:
self.canvas.unbind_all("<MouseWheel>")
except Exception:
pass
self._mw_binding = None
def _on_mousewheel(self, event):
try:
self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
except Exception:
pass
# ─── Data ─────────────────────────────────────────────────────────────────
def _load(self):
from models import get_today_checks
try:
self._all_sites = get_today_checks(self.current_user["id"])
except Exception as e:
show_error(f"Failed to load websites:\n{e}")
return
self._site_check_vars.clear()
self._apply_filter()
self._schedule_notifications()
def _apply_filter(self):
query = self._search_var.get().strip().lower()
if query:
filtered = [s for s in self._all_sites
if query in s["name"].lower()
or query in (s["url"] or "").lower()]
else:
filtered = self._all_sites
self._render_sites(filtered)
def _render_sites(self, sites: list):
for widget in self.sites_frame.winfo_children():
widget.destroy()
checked_count = sum(1 for s in self._all_sites if s["check_id"])
total = len(self._all_sites)
self.progress_bar["maximum"] = total
self.progress_bar["value"] = checked_count
self.progress_label.config(
text=f"Progress: {checked_count} / {total} checked"
+ (f" | Showing {len(sites)} of {total}" if len(sites) != total else "")
)
if not sites:
msg = ("No websites match your search."
if self._search_var.get().strip()
else "No active websites found. Please contact your administrator.")
ttk.Label(self.sites_frame, text=msg,
style="Dim.TLabel").pack(pady=40)
return
for site in sites:
self._render_site_card(site)
# ─── Site Card ────────────────────────────────────────────────────────────
def _render_site_card(self, site: dict):
wid = site["id"]
is_checked = bool(site["check_id"])
border_col = COLOURS["success"] if is_checked else COLOURS["border"]
card = tk.Frame(
self.sites_frame, bg=COLOURS["surface"], bd=2, relief="flat",
highlightbackground=border_col, highlightthickness=2,
)
card.pack(fill="x", padx=8, pady=5, ipady=4)
card.columnconfigure(2, weight=1)
# ── Bulk-select checkbox ───────────────────────────────────────────────
sel_var = tk.BooleanVar(value=False)
self._site_check_vars[wid] = sel_var
cb = tk.Checkbutton(
card, variable=sel_var,
bg=COLOURS["surface"],
activebackground=COLOURS["surface"],
selectcolor=COLOURS["surface2"],
cursor="hand2",
)
cb.grid(row=0, column=0, rowspan=3, padx=(10, 4), sticky="ns")
# ── Status indicator ──────────────────────────────────────────────────
status_char = "✔" if is_checked else "○"
status_col = COLOURS["success"] if is_checked else COLOURS["text_dim"]
tk.Label(card, text=status_char,
font=(FONT[0], 16, "bold"),
bg=COLOURS["surface"], fg=status_col,
width=2).grid(row=0, column=1, rowspan=2,
padx=(4, 8), pady=8, sticky="ns")
# ── Health indicator dot (updated asynchronously) ─────────────────────
cached = self._health_cache.get(wid)
if cached:
status_str, ms = cached
dot_col = {
"ok": COLOURS["success"],
"slow": COLOURS["warning"],
"restricted": COLOURS["warning"],
"down": COLOURS["danger"],
}.get(status_str, COLOURS["text_dim"])
dot_tip = {
"ok": f"Reachable ({ms}ms)",
"slow": f"Slow ({ms}ms)",
"restricted": f"Reachable — access restricted ({ms}ms)",
"down": "Unreachable",
}.get(status_str, "Unknown")
else:
dot_col = COLOURS["text_dim"]
dot_tip = "Checking..."
health_dot = tk.Label(card, text="●", font=(FONT[0], 9),
bg=COLOURS["surface"], fg=dot_col,
cursor="hand2")
health_dot.grid(row=0, column=5, padx=(0, 6), sticky="e")
health_dot.bind("<Enter>", lambda e, t=dot_tip: self._show_tooltip(e, t))
health_dot.bind("<Leave>", self._hide_tooltip)
# Trigger background health check if not cached
if wid not in self._health_cache:
self._health_cache[wid] = ("checking", 0)
threading.Thread(
target=self._check_site_health,
args=(wid, site["url"], health_dot),
daemon=True
).start()
# ── Site name + URL (clickable) ────────────────────────────────────────
name_lbl = tk.Label(card, text=site["name"],
font=FONT_BOLD, bg=COLOURS["surface"],
fg=COLOURS["accent"], cursor="hand2", anchor="w")
name_lbl.grid(row=0, column=2, sticky="ew", padx=4, pady=(8, 1))
name_lbl.bind("<Button-1>", lambda e, s=site: self._open_site(s))
url_lbl = tk.Label(card, text=site["url"],
font=FONT_SMALL, bg=COLOURS["surface"],
fg=COLOURS["text_dim"], cursor="hand2", anchor="w")
url_lbl.grid(row=1, column=2, sticky="ew", padx=4, pady=(0, 4))
url_lbl.bind("<Button-1>", lambda e, s=site: self._open_site(s))
# ── Metadata badges ────────────────────────────────────────────────────
badge_row = 2
if site.get("site_note"):
tk.Label(card, text=f"📝 {site['site_note']}",
font=FONT_SMALL, bg=COLOURS["surface"],
fg=COLOURS["text_dim"], anchor="w",
wraplength=400).grid(
row=badge_row, column=2, sticky="ew", padx=4, pady=(0, 2))
badge_row += 1
if site.get("shift_names"):
tk.Label(card, text=f"🕐 {site['shift_names']}",
font=FONT_SMALL, bg=COLOURS["surface"],
fg=COLOURS["accent"], anchor="w").grid(
row=badge_row, column=2, sticky="ew", padx=4, pady=(0, 2))
badge_row += 1
ct = site.get("check_type") or "daily"
ct_text = "📅 Daily" if ct == "daily" else "🗓 Weekly"
ct_colour = COLOURS["text_dim"] if ct == "daily" else COLOURS["warning"]
tk.Label(card, text=ct_text, font=FONT_SMALL,
bg=COLOURS["surface"], fg=ct_colour, anchor="w").grid(
row=badge_row, column=2, sticky="ew", padx=4, pady=(0, 4))
# User note if any
if site.get("user_note"):
tk.Label(card, text=f"Your note: {site['user_note']}",
font=FONT_SMALL, bg=COLOURS["surface"],
fg=COLOURS["warning"], anchor="w",
wraplength=350).grid(
row=badge_row + 1, column=2, columnspan=2,
sticky="ew", padx=4, pady=(0, 6))
# ── Action buttons ─────────────────────────────────────────────────────
btn_frame = tk.Frame(card, bg=COLOURS["surface"])
btn_frame.grid(row=0, column=3, rowspan=4,
padx=(8, 12), pady=8, sticky="ns")
check_text = "✔ Checked" if is_checked else "✔ Mark Checked"
check_bg = COLOURS["surface2"] if is_checked else COLOURS["success"]
tk.Button(
btn_frame, text=check_text,
command=lambda s=site: self._mark_checked(s),
bg=check_bg, fg=COLOURS["white"],
activebackground=COLOURS["success"],
activeforeground=COLOURS["white"],
relief="flat", font=FONT_BOLD, cursor="hand2",
padx=10, pady=6,
).pack(pady=(0, 6))
tk.Button(
btn_frame, text="📝 Add Note",
command=lambda s=site: self._open_note_dialog(s),
bg=COLOURS["surface2"], fg=COLOURS["text"],
activebackground=COLOURS["surface2"],
activeforeground=COLOURS["accent"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
).pack(pady=(0, 6))
# 🔑 Credentials button — only shown if this site has saved credentials
try:
from models import get_website_credentials
creds = get_website_credentials(wid)
except Exception:
creds = []
if creds:
tk.Button(
btn_frame, text="🔑 Credentials",
command=lambda s=site, c=creds: CredentialsPopup(self, s["name"], c),
bg=COLOURS["surface2"], fg=COLOURS["text"],
activebackground=COLOURS["accent"],
activeforeground=COLOURS["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
).pack()
# ─── Bulk Actions ─────────────────────────────────────────────────────────
def _select_all_unchecked(self):
"""Tick checkboxes for all currently visible unchecked sites."""
visible_ids = {
s["id"] for s in self._all_sites
if not s["check_id"]
and (not self._search_var.get().strip()
or self._search_var.get().strip().lower() in s["name"].lower()
or self._search_var.get().strip().lower() in (s["url"] or "").lower())
}
for wid, var in self._site_check_vars.items():
if wid in visible_ids:
var.set(True)
def _bulk_check(self):
"""Mark all ticked sites as checked in a single operation."""
selected = [wid for wid, var in self._site_check_vars.items()
if var.get()]
if not selected:
show_error("No sites selected. Tick the checkboxes first.")
return
from models import mark_website_checked
errors = []
for wid in selected:
try:
mark_website_checked(self.current_user["id"], wid)
except Exception as e:
errors.append(str(e))
if errors:
show_error(f"Some sites could not be marked:\n" + "\n".join(errors))
else:
show_info(f"{len(selected)} site(s) marked as checked.")
logger.info(f"Bulk check: user {self.current_user['username']} "
f"marked {len(selected)} sites.")
self._load()
# ─── Health Pre-check ─────────────────────────────────────────────────────
def _check_site_health(self, wid: int, url: str, dot_label: tk.Label):
"""Background thread: HEAD request to url; updates health_cache + dot colour.
Status semantics:
ok — 2xx / 3xx response, <= 3 000 ms
slow — 2xx / 3xx response, > 3 000 ms
restricted — 4xx response (server reachable but denying HEAD access)
down — network error, timeout, or 5xx (server not responding)
4xx responses are intentionally treated as "restricted" (amber) rather
than "down" (red) because the server is clearly reachable — many sites
block unauthenticated HEAD requests with 401/403 even when fully
operational.
"""
import urllib.request
import urllib.error
import time
if not url.startswith(("http://", "https://")):
url = "https://" + url
try:
req = urllib.request.Request(url, method="HEAD")
req.add_header("User-Agent", "WebsiteChecker/1.0 HealthProbe")
t0 = time.monotonic()
urllib.request.urlopen(req, timeout=6)
ms = int((time.monotonic() - t0) * 1000)
status = "slow" if ms > 3000 else "ok"
except urllib.error.HTTPError as e:
ms = int((time.monotonic() - t0) * 1000)
if 400 <= e.code < 500:
# Server responded — it is reachable but blocking this probe
status = "restricted"
else:
# 5xx: server error — treat as down
ms = 0
status = "down"
except Exception:
ms = 0
status = "down"
self._health_cache[wid] = (status, ms)
col = {
"ok": COLOURS["success"],
"slow": COLOURS["warning"],
"restricted": COLOURS["warning"],
"down": COLOURS["danger"],
}.get(status, COLOURS["text_dim"])
# Update the dot on the main thread
try:
dot_label.after(0, lambda: dot_label.config(fg=col))
except Exception:
pass
def _show_tooltip(self, event, text: str):
x = event.widget.winfo_rootx() + 20
y = event.widget.winfo_rooty() + 20
self._tooltip = tk.Toplevel()
self._tooltip.overrideredirect(True)
self._tooltip.geometry(f"+{x}+{y}")
tk.Label(self._tooltip, text=text,
bg=COLOURS["surface2"], fg=COLOURS["text"],
relief="flat", padx=8, pady=4,
font=FONT_SMALL).pack()
def _hide_tooltip(self, event=None):
if hasattr(self, "_tooltip"):
try:
self._tooltip.destroy()
except Exception:
pass
# ─── Keyboard Shortcuts ────────────────────────────────────────────────────
def _bind_shortcuts(self):
"""Bind keyboard shortcuts to this frame only (not globally)."""
self._shortcut_ids = []
bindings = [
("<F5>", lambda e: self._load()),
("<Control-f>", lambda e: self._focus_search()),
("<Escape>", lambda e: self._clear_search()),
("<Control-a>", lambda e: self._select_all_unchecked()),
("<Control-Return>", lambda e: self._bulk_check()),
]
for seq, cmd in bindings:
bid = self.bind(seq, cmd)
self._shortcut_ids.append((seq, bid))
def _unbind_shortcuts(self):
"""Remove all shortcut bindings — called from destroy()."""
for seq, bid in getattr(self, "_shortcut_ids", []):
try:
self.unbind(seq, bid)
except Exception:
pass
self._shortcut_ids = []
def _focus_search(self):
"""Ctrl+F — focus the search entry."""
try:
# Walk widget tree to find the search entry
for w in self.winfo_children():
for child in w.winfo_children():
if isinstance(child, tk.Entry):
child.focus_set()
return
except Exception:
pass
def _clear_search(self):
"""Escape — clear the search box."""
self._search_var.set("")
# ─── Individual Actions ────────────────────────────────────────────────────
def _open_site(self, site: dict):
url = site["url"]
if not url.startswith(("http://", "https://")):
url = "https://" + url
try:
webbrowser.open(url)
logger.info(f"User {self.current_user['username']} opened URL: {url}")
except Exception as e:
show_error(f"Could not open URL:\n{e}")
def _mark_checked(self, site: dict):
try:
from models import mark_website_checked
mark_website_checked(self.current_user["id"], site["id"])
logger.info(
f"User {self.current_user['username']} checked '{site['name']}'.")
self._load()
except Exception as e:
show_error(f"Could not mark as checked:\n{e}")
def _open_note_dialog(self, site: dict):
NoteDialog(self, self.current_user, site, on_save=self._load)
# ─── Shift-end Notifications ──────────────────────────────────────────────
def _schedule_notifications(self):
"""Check every 60 s whether any shift end-time triggers a reminder."""
if self._notify_job:
try:
self.after_cancel(self._notify_job)
except Exception:
pass
self._check_notifications()
self._notify_job = self.after(60_000, self._schedule_notifications)
def _check_notifications(self):
"""Fire a desktop notification if a shift ends within NOTIFY_MINUTES_BEFORE."""
from models import get_unchecked_sites_for_user
import datetime as dt
try:
unchecked = get_unchecked_sites_for_user(self.current_user["id"])
except Exception:
return
now = dt.datetime.now().time()
for site in unchecked:
end_time = site.get("end_time")
if not end_time:
continue
# MySQL returns timedelta for TIME columns
if hasattr(end_time, "total_seconds"):
total_secs = int(end_time.total_seconds())
end_h, rem = divmod(total_secs, 3600)
end_m = rem // 60
end_t = dt.time(end_h % 24, end_m)
else:
try:
end_t = dt.time.fromisoformat(str(end_time)[:5])
except Exception:
continue
# Compute minutes until shift end
now_mins = now.hour * 60 + now.minute
end_mins = end_t.hour * 60 + end_t.minute
diff = end_mins - now_mins
key = (site["id"], end_t)
if 0 < diff <= NOTIFY_MINUTES_BEFORE and key not in self._notified_sites:
self._notified_sites.add(key)
self._fire_notification(site["name"], diff, end_t)
def _fire_notification(self, site_name: str, minutes_left: int, end_time):
title = "Shift Reminder"
message = (f"'{site_name}' is unchecked — "
f"shift ends at {end_time.strftime('%H:%M')} "
f"({minutes_left} min remaining).")
logger.info(f"Notification: {message}")
# Try plyer desktop notification; fall back to Tkinter toast
notified = False
try:
from plyer import notification
notification.notify(
title=title,
message=message,
app_name="Website Checker",
timeout=10,
)
notified = True
except Exception:
pass
if not notified:
self._show_toast(title, message)
def _show_toast(self, title: str, message: str):
"""Fallback in-app toast notification."""
try:
toast = tk.Toplevel(self)
toast.overrideredirect(True)
toast.attributes("-topmost", True)
toast.configure(bg=COLOURS["warning"])
tk.Label(toast, text=title, font=FONT_BOLD,
bg=COLOURS["warning"], fg=COLOURS["white"]).pack(
padx=16, pady=(10, 2))
tk.Label(toast, text=message, font=FONT_SMALL,
bg=COLOURS["warning"], fg=COLOURS["white"],
wraplength=300, justify="left").pack(
padx=16, pady=(0, 10))
# Position bottom-right
toast.update_idletasks()
sw = toast.winfo_screenwidth()
sh = toast.winfo_screenheight()
tw = toast.winfo_reqwidth()
th = toast.winfo_reqheight()
toast.geometry(f"+{sw - tw - 20}+{sh - th - 60}")
# Auto-dismiss after 8 seconds
toast.after(8000, toast.destroy)
except Exception as e:
logger.warning(f"Toast notification failed: {e}")
# ─── Credentials Popup ────────────────────────────────────────────────────────
class CredentialsPopup(tk.Toplevel):
def __init__(self, parent, site_name, credentials: list):
super().__init__(parent)
self.title(f"Credentials — {site_name}")
self.configure(bg=COLOURS["bg"])
self.resizable(False, False)
self.grab_set()
ttk.Label(self, text=f"Login credentials for {site_name}",
style="Heading.TLabel").pack(padx=20, pady=(16, 8))
for cred in credentials:
frame = ttk.Frame(self, style="Surface.TFrame")
frame.pack(fill="x", padx=20, pady=4, ipady=6)
label = cred.get("label") or "Default"
tk.Label(frame, text=label, font=FONT_BOLD,
bg=COLOURS["surface"], fg=COLOURS["accent"]).grid(
row=0, column=0, columnspan=6, sticky="w", padx=10, pady=(4, 2))
# ── Username row ──────────────────────────────────────────────────
tk.Label(frame, text="Username:", bg=COLOURS["surface"],
fg=COLOURS["text_dim"]).grid(
row=1, column=0, sticky="w", padx=(10, 4))
tk.Label(frame, text=cred["username"], bg=COLOURS["surface"],
fg=COLOURS["text"], font=FONT_BOLD).grid(
row=1, column=1, sticky="w", padx=(0, 8))
user_copy_btn = tk.Button(
frame, text="📋", width=3,
bg=COLOURS["surface2"], fg=COLOURS["text_dim"],
activebackground=COLOURS["accent"], activeforeground=COLOURS["white"],
relief="flat", cursor="hand2", font=FONT_SMALL,
)
user_copy_btn.grid(row=1, column=2, padx=(0, 16))
def _copy_user(c=cred, b=user_copy_btn):
self.clipboard_clear()
self.clipboard_append(c["username"])
b.config(text="✔", fg=COLOURS["success"])
self.after(2000, lambda: b.config(text="📋", fg=COLOURS["text_dim"]))
user_copy_btn.config(command=_copy_user)
# ── Password row ──────────────────────────────────────────────────
tk.Label(frame, text="Password:", bg=COLOURS["surface"],
fg=COLOURS["text_dim"]).grid(
row=1, column=3, sticky="w", padx=(0, 4))
pw_var = tk.StringVar(value="••••••••")
tk.Label(frame, textvariable=pw_var, bg=COLOURS["surface"],
fg=COLOURS["text"], font=FONT_BOLD).grid(
row=1, column=4, sticky="w", padx=(0, 4))
revealed = [False]
def toggle(c=cred, v=pw_var, r=revealed):
r[0] = not r[0]
v.set(c["password"] if r[0] else "••••••••")
tk.Button(frame, text="👁", command=toggle,
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
relief="flat", cursor="hand2").grid(
row=1, column=5, padx=(0, 4))
pw_copy_btn = tk.Button(
frame, text="📋", width=3,
bg=COLOURS["surface2"], fg=COLOURS["text_dim"],
activebackground=COLOURS["accent"], activeforeground=COLOURS["white"],
relief="flat", cursor="hand2", font=FONT_SMALL,
)
pw_copy_btn.grid(row=1, column=6, padx=(0, 10))
def _copy_pw(c=cred, b=pw_copy_btn):
self.clipboard_clear()
self.clipboard_append(c["password"])
b.config(text="✔", fg=COLOURS["success"])
# Auto-clear clipboard after 15 s for security
self.after(15_000, self.clipboard_clear)
self.after(2000, lambda: b.config(text="📋", fg=COLOURS["text_dim"]))
pw_copy_btn.config(command=_copy_pw)
ttk.Button(self, text="Close", command=self.destroy).pack(pady=16)
self._centre()
def _centre(self):
self.update_idletasks()
w = self.winfo_reqwidth() + 40
h = self.winfo_reqheight() + 20
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
# ─── Note Dialog ──────────────────────────────────────────────────────────────
class NoteDialog(tk.Toplevel):
def __init__(self, parent, current_user, site, on_save):
super().__init__(parent)
self.current_user = current_user
self.site = site
self.on_save = on_save
self.title(f"Note — {site['name']}")
self.configure(bg=COLOURS["bg"])
self.resizable(False, False)
self.grab_set()
self._build_ui()
self._centre()
def _centre(self):
self.update_idletasks()
w, h = 460, 280
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
def _build_ui(self):
ttk.Label(self, text=f"Note for: {self.site['name']}",
style="Heading.TLabel").pack(
padx=20, pady=(16, 8), anchor="w")
frame, self.note_txt = scrolled_text(self, height=6, width=50)
frame.pack(padx=20, pady=4, fill="x")
if self.site.get("user_note"):
self.note_txt.insert("1.0", self.site["user_note"])
btn_frame = ttk.Frame(self)
btn_frame.pack(fill="x", padx=20, pady=(12, 16))
ttk.Button(btn_frame, text="Save Note",
command=self._save).pack(side="right", padx=(6, 0))
ttk.Button(btn_frame, text="Cancel", style="Ghost.TButton",
command=self.destroy).pack(side="right")
def _save(self):
note = self.note_txt.get("1.0", "end-1c").strip()
try:
if self.site.get("check_id"):
from models import update_check_note
update_check_note(self.current_user["id"],
self.site["id"], note)
else:
from models import mark_website_checked
mark_website_checked(self.current_user["id"],
self.site["id"], note)
logger.info(f"Note saved for '{self.site['name']}' "
f"by {self.current_user['username']}.")
show_info("Note saved successfully.")
self.on_save()
self.destroy()
except Exception as e:
show_error(f"Failed to save note:\n{e}")