added svg's for theme + configurable accent colors + bugfixes
@@ -217,6 +217,12 @@ origin = ""
|
||||
port = 8080
|
||||
on_frame = ""
|
||||
|
||||
[ui.web.theme]
|
||||
# Theme customization for the web interface. Use RGB values for the accent color.
|
||||
accent_r = 76
|
||||
accent_g = 175
|
||||
accent_b = 80
|
||||
|
||||
[ui.display]
|
||||
enabled = false
|
||||
rotation = 180
|
||||
@@ -257,4 +263,4 @@ mount = "/var/tmp/pwnagotchi"
|
||||
size = "10M"
|
||||
sync = 3600
|
||||
zram = true
|
||||
rsync = true
|
||||
rsync = true
|
||||
@@ -9,11 +9,14 @@ import threading
|
||||
import glob
|
||||
from flask import render_template_string
|
||||
|
||||
|
||||
class AutoBackup(plugins.Plugin):
|
||||
__author__ = 'WPA2'
|
||||
__version__ = '2.2'
|
||||
__license__ = 'GPL3'
|
||||
__description__ = 'Backs up Pwnagotchi configuration and data, keeping recent backups.'
|
||||
__author__ = "WPA2"
|
||||
__version__ = "2.2"
|
||||
__license__ = "GPL3"
|
||||
__description__ = (
|
||||
"Backs up Pwnagotchi configuration and data, keeping recent backups."
|
||||
)
|
||||
|
||||
# Hardcoded defaults for Pwnagotchi
|
||||
DEFAULT_FILES = [
|
||||
@@ -32,7 +35,7 @@ class AutoBackup(plugins.Plugin):
|
||||
"/home/pi/.profile",
|
||||
"/home/pi/.wpa_sec_uploads",
|
||||
]
|
||||
|
||||
|
||||
DEFAULT_INTERVAL_SECONDS = 60 * 60 # 60 minutes
|
||||
DEFAULT_MAX_BACKUPS = 3
|
||||
DEFAULT_EXCLUDE = [
|
||||
@@ -45,7 +48,7 @@ class AutoBackup(plugins.Plugin):
|
||||
self.ready = False
|
||||
self.tries = 0
|
||||
self.last_not_due_logged = 0
|
||||
self.status_file = '/root/.auto-backup'
|
||||
self.status_file = "/root/.auto-backup"
|
||||
self.status = StatusFile(self.status_file)
|
||||
self.lock = threading.Lock()
|
||||
self.backup_in_progress = False
|
||||
@@ -54,41 +57,63 @@ class AutoBackup(plugins.Plugin):
|
||||
|
||||
def on_loaded(self):
|
||||
"""Validate only required option: backup_location"""
|
||||
if 'backup_location' not in self.options or self.options['backup_location'] is None:
|
||||
if (
|
||||
"backup_location" not in self.options
|
||||
or self.options["backup_location"] is None
|
||||
):
|
||||
logging.error("AUTO-BACKUP: Option 'backup_location' is not set.")
|
||||
return
|
||||
|
||||
self.hostname = socket.gethostname()
|
||||
|
||||
|
||||
# Read config with internal defaults - DO NOT modify self.options
|
||||
self.files = self.options.get('files', self.DEFAULT_FILES)
|
||||
self.interval_seconds = self.options.get('interval_seconds', self.DEFAULT_INTERVAL_SECONDS)
|
||||
self.max_backups = self.options.get('max_backups_to_keep', self.DEFAULT_MAX_BACKUPS)
|
||||
self.exclude = self.options.get('exclude', self.DEFAULT_EXCLUDE)
|
||||
self.include = self.options.get('include', [])
|
||||
|
||||
self.files = self.options.get("files", self.DEFAULT_FILES)
|
||||
self.interval_seconds = self.options.get(
|
||||
"interval_seconds", self.DEFAULT_INTERVAL_SECONDS
|
||||
)
|
||||
self.max_backups = self.options.get(
|
||||
"max_backups_to_keep", self.DEFAULT_MAX_BACKUPS
|
||||
)
|
||||
self.exclude = self.options.get("exclude", self.DEFAULT_EXCLUDE)
|
||||
self.include = self.options.get("include", [])
|
||||
|
||||
# Handle commands: if old format, use correct default internally
|
||||
commands = self.options.get('commands', ["tar", "czf"])
|
||||
if isinstance(commands, str) or (isinstance(commands, list) and len(commands) == 1 and isinstance(commands[0], str) and '{' in str(commands)):
|
||||
logging.warning("AUTO-BACKUP: Old command format detected in config, using default: tar czf")
|
||||
commands = self.options.get("commands", ["tar", "czf"])
|
||||
if isinstance(commands, str) or (
|
||||
isinstance(commands, list)
|
||||
and len(commands) == 1
|
||||
and isinstance(commands[0], str)
|
||||
and "{" in str(commands)
|
||||
):
|
||||
logging.warning(
|
||||
"AUTO-BACKUP: Old command format detected in config, using default: tar czf"
|
||||
)
|
||||
self.commands = ["tar", "czf"]
|
||||
elif not commands:
|
||||
self.commands = ["tar", "czf"]
|
||||
else:
|
||||
self.commands = commands
|
||||
|
||||
|
||||
# Validate include paths if specified
|
||||
if self.include:
|
||||
if not isinstance(self.include, list):
|
||||
self.include = [self.include]
|
||||
|
||||
|
||||
for path in self.include:
|
||||
if not os.path.exists(path):
|
||||
logging.warning(f"AUTO-BACKUP: include path '{path}' does not exist, will skip if still missing at backup time")
|
||||
|
||||
logging.warning(
|
||||
f"AUTO-BACKUP: include path '{path}' does not exist, will skip if still missing at backup time"
|
||||
)
|
||||
|
||||
self.ready = True
|
||||
include_msg = f", includes: {len(self.include)} additional path(s)" if self.include else ""
|
||||
logging.info(f"AUTO-BACKUP: Plugin loaded for host '{self.hostname}'. Interval: 60min, Backups kept: {self.max_backups}{include_msg}")
|
||||
include_msg = (
|
||||
f", includes: {len(self.include)} additional path(s)"
|
||||
if self.include
|
||||
else ""
|
||||
)
|
||||
logging.info(
|
||||
f"AUTO-BACKUP: Plugin loaded for host '{self.hostname}'. Interval: 60min, Backups kept: {self.max_backups}{include_msg}"
|
||||
)
|
||||
|
||||
def is_backup_due(self):
|
||||
"""Check if backup is due based on interval."""
|
||||
@@ -101,62 +126,74 @@ class AutoBackup(plugins.Plugin):
|
||||
def _cleanup_old_backups(self):
|
||||
"""Deletes the oldest backups if we exceed the limit."""
|
||||
try:
|
||||
backup_dir = self.options['backup_location']
|
||||
backup_dir = self.options["backup_location"]
|
||||
max_keep = self.max_backups
|
||||
|
||||
|
||||
# Filter by this device's hostname
|
||||
search_pattern = os.path.join(backup_dir, f"{self.hostname}-backup-*.tar.gz")
|
||||
search_pattern = os.path.join(
|
||||
backup_dir, f"{self.hostname}-backup-*.tar.gz"
|
||||
)
|
||||
files = glob.glob(search_pattern)
|
||||
|
||||
|
||||
if not files:
|
||||
logging.debug("AUTO-BACKUP: No backup files found for cleanup")
|
||||
return
|
||||
|
||||
|
||||
# Sort files by modification time (oldest first)
|
||||
files.sort(key=os.path.getmtime)
|
||||
|
||||
|
||||
# Calculate how many to delete
|
||||
if len(files) > max_keep:
|
||||
num_to_delete = len(files) - max_keep
|
||||
logging.info(f"AUTO-BACKUP: Found {len(files)} backups, keeping {max_keep}, deleting {num_to_delete} old backup(s)...")
|
||||
|
||||
logging.info(
|
||||
f"AUTO-BACKUP: Found {len(files)} backups, keeping {max_keep}, deleting {num_to_delete} old backup(s)..."
|
||||
)
|
||||
|
||||
for old_file in files[:num_to_delete]:
|
||||
try:
|
||||
os.remove(old_file)
|
||||
logging.info(f"AUTO-BACKUP: Deleted: {os.path.basename(old_file)}")
|
||||
logging.info(
|
||||
f"AUTO-BACKUP: Deleted: {os.path.basename(old_file)}"
|
||||
)
|
||||
except OSError as e:
|
||||
logging.error(f"AUTO-BACKUP: Failed to delete {old_file}: {e}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"AUTO-BACKUP: Cleanup error: {e}")
|
||||
|
||||
def _run_backup_thread(self, agent, existing_files):
|
||||
"""Execute backup in separate thread."""
|
||||
try:
|
||||
backup_location = self.options['backup_location']
|
||||
|
||||
backup_location = self.options["backup_location"]
|
||||
|
||||
# Create backup directory if it doesn't exist
|
||||
if not os.path.exists(backup_location):
|
||||
try:
|
||||
os.makedirs(backup_location)
|
||||
logging.info(f"AUTO-BACKUP: Created backup directory: {backup_location}")
|
||||
logging.info(
|
||||
f"AUTO-BACKUP: Created backup directory: {backup_location}"
|
||||
)
|
||||
except OSError as e:
|
||||
logging.error(f"AUTO-BACKUP: Failed to create backup directory: {e}")
|
||||
logging.error(
|
||||
f"AUTO-BACKUP: Failed to create backup directory: {e}"
|
||||
)
|
||||
return
|
||||
|
||||
# Add timestamp to filename
|
||||
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
backup_file = os.path.join(backup_location, f"{self.hostname}-backup-{timestamp}.tar.gz")
|
||||
backup_file = os.path.join(
|
||||
backup_location, f"{self.hostname}-backup-{timestamp}.tar.gz"
|
||||
)
|
||||
|
||||
# Try to update display if agent is available
|
||||
if agent:
|
||||
try:
|
||||
display = agent.view()
|
||||
display.set('status', 'Backing up...')
|
||||
display.set("status", "Backing up...")
|
||||
display.update()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
logging.info(f"AUTO-BACKUP: Starting backup to {backup_file}...")
|
||||
|
||||
# Build command
|
||||
@@ -166,40 +203,42 @@ class AutoBackup(plugins.Plugin):
|
||||
# Add exclusions
|
||||
for pattern in self.exclude:
|
||||
command_list.append(f"--exclude={pattern}")
|
||||
|
||||
|
||||
# Add files to backup
|
||||
command_list.extend(existing_files)
|
||||
|
||||
|
||||
# Execute backup command
|
||||
process = subprocess.Popen(
|
||||
command_list,
|
||||
shell=False,
|
||||
stdin=None,
|
||||
stdout=open("/dev/null", "w"),
|
||||
stderr=subprocess.PIPE
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
_, stderr_output = process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise OSError(f"Backup command failed with code {process.returncode}: {stderr_output.decode('utf-8').strip()}")
|
||||
raise OSError(
|
||||
f"Backup command failed with code {process.returncode}: {stderr_output.decode('utf-8').strip()}"
|
||||
)
|
||||
|
||||
logging.info(f"AUTO-BACKUP: Backup successful: {backup_file}")
|
||||
|
||||
|
||||
# Run cleanup after successful backup
|
||||
self._cleanup_old_backups()
|
||||
|
||||
|
||||
# Try to update display if agent is available
|
||||
if agent:
|
||||
try:
|
||||
display = agent.view()
|
||||
display.set('status', 'Backup done!')
|
||||
display.set("status", "Backup done!")
|
||||
display.update()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
# Update status file timestamp
|
||||
self.status.update()
|
||||
|
||||
|
||||
# Reset try counter on success
|
||||
self.tries = 0
|
||||
|
||||
@@ -213,70 +252,88 @@ class AutoBackup(plugins.Plugin):
|
||||
"""Called when Pwnagotchi is ready. Set up backup scheduler."""
|
||||
if not self.ready:
|
||||
return
|
||||
|
||||
|
||||
self._agent = agent
|
||||
|
||||
|
||||
# Start background scheduler thread
|
||||
scheduler_thread = threading.Thread(
|
||||
target=self._backup_scheduler_loop,
|
||||
daemon=True,
|
||||
name="AutoBackupScheduler"
|
||||
target=self._backup_scheduler_loop, daemon=True, name="AutoBackupScheduler"
|
||||
)
|
||||
scheduler_thread.start()
|
||||
|
||||
|
||||
logging.info("AUTO-BACKUP: Periodic backup scheduler started")
|
||||
|
||||
|
||||
def on_webhook(self, path, request):
|
||||
"""Handle web UI requests."""
|
||||
if request.method == "GET":
|
||||
if path == "/" or not path:
|
||||
action_path = request.path if request.path.endswith("/backup") else "%s/backup" % request.path
|
||||
action_path = (
|
||||
request.path
|
||||
if request.path.endswith("/backup")
|
||||
else "%s/backup" % request.path
|
||||
)
|
||||
ret = '<html><head><title>AUTO Backup</title><meta name="csrf_token" content="{{ csrf_token() }}"></head><body>'
|
||||
ret += '<h1>AUTO Backup</h1>'
|
||||
ret += '<p>Status: '
|
||||
ret += "<h1>AUTO Backup</h1>"
|
||||
ret += "<p>Status: "
|
||||
if self.backup_in_progress:
|
||||
ret += '<b>Backup in progress...</b>'
|
||||
ret += "<b>Backup in progress...</b>"
|
||||
else:
|
||||
ret += '<b>Ready</b>'
|
||||
ret += '</p>'
|
||||
ret += "<b>Ready</b>"
|
||||
ret += "</p>"
|
||||
ret += '<form method="POST" action="%s">' % action_path
|
||||
ret += '<input id="csrf_token" name="csrf_token" type="hidden" value="{{ csrf_token() }}">'
|
||||
ret += '<input type="submit" value="Start Manual Backup" style="padding: 10px 20px; font-size: 16px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;">'
|
||||
ret += '</form>'
|
||||
ret += '<hr>'
|
||||
ret += '<h2>Configuration</h2>'
|
||||
ret += '<input type="submit" value="Start Manual Backup" class="btn primary">'
|
||||
ret += "</form>"
|
||||
ret += "<hr>"
|
||||
ret += "<h2>Configuration</h2>"
|
||||
ret += '<table border="1" cellpadding="5">'
|
||||
ret += '<tr><td><b>Backup Location:</b></td><td>' + self.options.get('backup_location', 'Not set') + '</td></tr>'
|
||||
ret += '<tr><td><b>Interval:</b></td><td>' + str(self.interval_seconds // 60) + ' minutes</b></td></tr>'
|
||||
ret += '<tr><td><b>Max Backups:</b></td><td>' + str(self.max_backups) + '</td></tr>'
|
||||
ret += '<tr><td><b>Include Paths:</b></td><td>' + (', '.join(self.include) if self.include else 'None') + '</td></tr>'
|
||||
ret += '</table>'
|
||||
ret += '</body></html>'
|
||||
ret += (
|
||||
"<tr><td><b>Backup Location:</b></td><td>"
|
||||
+ self.options.get("backup_location", "Not set")
|
||||
+ "</td></tr>"
|
||||
)
|
||||
ret += (
|
||||
"<tr><td><b>Interval:</b></td><td>"
|
||||
+ str(self.interval_seconds // 60)
|
||||
+ " minutes</b></td></tr>"
|
||||
)
|
||||
ret += (
|
||||
"<tr><td><b>Max Backups:</b></td><td>"
|
||||
+ str(self.max_backups)
|
||||
+ "</td></tr>"
|
||||
)
|
||||
ret += (
|
||||
"<tr><td><b>Include Paths:</b></td><td>"
|
||||
+ (", ".join(self.include) if self.include else "None")
|
||||
+ "</td></tr>"
|
||||
)
|
||||
ret += "</table>"
|
||||
ret += "</body></html>"
|
||||
return render_template_string(ret)
|
||||
|
||||
|
||||
elif request.method == "POST":
|
||||
if path == "backup" or path == "/backup":
|
||||
result = self.manual_backup(self._agent)
|
||||
ret = '<html><head><title>AUTO Backup</title><meta name="csrf_token" content="{{ csrf_token() }}"></head><body>'
|
||||
ret += '<h1>AUTO Backup</h1>'
|
||||
ret += '<p><b>' + result['status'] + '</b></p>'
|
||||
ret += "<h1>AUTO Backup</h1>"
|
||||
ret += "<p><b>" + result["status"] + "</b></p>"
|
||||
ret += '<a href="/plugins/auto_backup/">Back</a>'
|
||||
ret += '</body></html>'
|
||||
ret += "</body></html>"
|
||||
return render_template_string(ret)
|
||||
|
||||
|
||||
return "Not found"
|
||||
|
||||
|
||||
def _backup_scheduler_loop(self):
|
||||
"""Background thread that checks if backup is due every minute."""
|
||||
while True:
|
||||
try:
|
||||
if self.ready:
|
||||
agent = getattr(self, '_agent', None)
|
||||
agent = getattr(self, "_agent", None)
|
||||
self._periodic_backup_check(agent)
|
||||
time.sleep(60)
|
||||
except Exception as e:
|
||||
logging.error(f"AUTO-BACKUP: Scheduler error: {e}")
|
||||
|
||||
|
||||
def _get_backup_files(self):
|
||||
"""Collect all files to backup."""
|
||||
existing_files = list(filter(os.path.exists, self.files))
|
||||
@@ -286,51 +343,51 @@ class AutoBackup(plugins.Plugin):
|
||||
existing_files.append(path)
|
||||
logging.debug(f"AUTO-BACKUP: Added include path: {path}")
|
||||
return existing_files
|
||||
|
||||
|
||||
def _periodic_backup_check(self, agent=None):
|
||||
"""Periodic backup check."""
|
||||
if agent is None:
|
||||
agent = getattr(self, '_agent', None)
|
||||
|
||||
agent = getattr(self, "_agent", None)
|
||||
|
||||
if not self.ready or self.backup_in_progress:
|
||||
return
|
||||
|
||||
|
||||
if self.tries >= 3:
|
||||
return
|
||||
|
||||
|
||||
if not self.is_backup_due():
|
||||
return
|
||||
|
||||
|
||||
existing_files = self._get_backup_files()
|
||||
if not existing_files:
|
||||
logging.warning("AUTO-BACKUP: No files to backup exist")
|
||||
return
|
||||
|
||||
|
||||
self.backup_in_progress = True
|
||||
backup_thread = threading.Thread(
|
||||
target=self._run_backup_thread,
|
||||
args=(agent, existing_files),
|
||||
daemon=True,
|
||||
name="AutoBackupThread"
|
||||
name="AutoBackupThread",
|
||||
)
|
||||
backup_thread.start()
|
||||
logging.debug("AUTO-BACKUP: Backup thread started")
|
||||
|
||||
|
||||
def manual_backup(self, agent):
|
||||
"""Manually trigger a backup."""
|
||||
if self.backup_in_progress:
|
||||
return {"status": "Backup already in progress"}
|
||||
|
||||
|
||||
existing_files = self._get_backup_files()
|
||||
if not existing_files:
|
||||
return {"status": "No files to backup"}
|
||||
|
||||
|
||||
self.backup_in_progress = True
|
||||
backup_thread = threading.Thread(
|
||||
target=self._run_backup_thread,
|
||||
args=(agent, existing_files),
|
||||
daemon=True,
|
||||
name="AutoBackupThread"
|
||||
name="AutoBackupThread",
|
||||
)
|
||||
backup_thread.start()
|
||||
logging.info("AUTO-BACKUP: Manual backup triggered")
|
||||
|
||||
@@ -60,7 +60,7 @@ TEMPLATE = """
|
||||
#searchText:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 15px rgba(76, 175, 80, 0.1);
|
||||
box-shadow: 0 0 15px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1);
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ TEMPLATE = """
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: rgba(76, 175, 80, 0.05);
|
||||
background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ INDEX = """
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: rgba(76, 175, 80, 0.05);
|
||||
background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ TEMPLATE = """
|
||||
.stat-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.1);
|
||||
box-shadow: 0 6px 20px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@@ -133,7 +133,7 @@ TEMPLATE = """
|
||||
|
||||
div.chart:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 8px 25px rgba(76, 175, 80, 0.1);
|
||||
box-shadow: 0 8px 25px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1);
|
||||
}
|
||||
|
||||
div.chart canvas {
|
||||
@@ -240,6 +240,14 @@ TEMPLATE = """
|
||||
}
|
||||
}
|
||||
|
||||
function getTransparentColor(color) {
|
||||
// Convert rgb() to rgba() with 0.2 opacity, or append hex opacity
|
||||
if (color.startsWith('rgb(')) {
|
||||
return color.replace('rgb(', 'rgba(').replace(')', ', 0.2)');
|
||||
}
|
||||
return color + '33'; // hex format
|
||||
}
|
||||
|
||||
function createChart(elementId, title, data) {
|
||||
const container = document.getElementById(elementId);
|
||||
if (!container || !data.values || data.values.length === 0) return;
|
||||
@@ -261,7 +269,7 @@ TEMPLATE = """
|
||||
label: data.labels[index],
|
||||
data: chartData,
|
||||
borderColor: color,
|
||||
backgroundColor: color + '33',
|
||||
backgroundColor: getTransparentColor(color),
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.1,
|
||||
@@ -353,7 +361,15 @@ TEMPLATE = """
|
||||
}
|
||||
|
||||
function getChartColor(index) {
|
||||
return ['#4caf50', '#ff9800', '#2196f3', '#f44336', '#9c27b0', '#00bcd4'][index % 6];
|
||||
// Get accent color from CSS root variables
|
||||
const root = document.documentElement;
|
||||
const r = getComputedStyle(root).getPropertyValue('--accent-r').trim();
|
||||
const g = getComputedStyle(root).getPropertyValue('--accent-g').trim();
|
||||
const b = getComputedStyle(root).getPropertyValue('--accent-b').trim();
|
||||
const accentColor = `rgb(${r},${g},${b})`;
|
||||
// Use accent color as first chart color, then secondary colors
|
||||
const colors = [accentColor, '#ff9800', '#2196f3', '#f44336', '#9c27b0', '#00bcd4'];
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
|
||||
async function updateStats() {
|
||||
|
||||
@@ -57,32 +57,6 @@ INDEX = """
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Add Button */
|
||||
#btnAdd {
|
||||
padding: 10px 14px;
|
||||
min-width: auto;
|
||||
background-color: var(--accent);
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-pixel);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
#btnAdd:hover {
|
||||
background-color: var(--accent-hover);
|
||||
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
#btnAdd:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Wrapper spans */
|
||||
#divTop > span {
|
||||
display: flex;
|
||||
@@ -131,7 +105,7 @@ INDEX = """
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: rgba(76, 175, 80, 0.05);
|
||||
background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -203,51 +177,9 @@ INDEX = """
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn-save,
|
||||
.btn-save-caution {
|
||||
#divSaveTop .btn {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 12px 20px;
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font-pixel);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background-color: var(--accent);
|
||||
color: #000;
|
||||
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
.btn-save:hover {
|
||||
background-color: var(--accent-hover);
|
||||
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-save:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-save-caution {
|
||||
background-color: var(--danger);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(255, 85, 85, 0.2);
|
||||
}
|
||||
|
||||
.btn-save-caution:hover {
|
||||
background-color: var(--danger-hover);
|
||||
box-shadow: 0 4px 12px rgba(255, 85, 85, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-save-caution:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@@ -281,12 +213,6 @@ INDEX = """
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-save,
|
||||
.btn-save-caution {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
@@ -325,13 +251,6 @@ INDEX = """
|
||||
margin-bottom: 70px;
|
||||
}
|
||||
|
||||
.btn-save,
|
||||
.btn-save-caution {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Mobile table display */
|
||||
table, tr, td {
|
||||
padding: 0;
|
||||
@@ -399,14 +318,14 @@ INDEX = """
|
||||
<div id="divTop">
|
||||
<input type="text" id="searchText" placeholder="Search for options ..." title="Type an option name">
|
||||
<span><select id="selAddType"><option value="text">Text</option><option value="number">Number</option></select></span>
|
||||
<span><button id="btnAdd" type="button" onclick="addOption()">+</button></span>
|
||||
<span><button class="btn primary" type="button" onclick="addOption()">+</button></span>
|
||||
</div>
|
||||
|
||||
<div class="table-container" id="content"></div>
|
||||
|
||||
<div id="divSaveTop">
|
||||
<button class="btn-save" type="button" onclick="saveConfig()">Save and restart</button>
|
||||
<button class="btn-save-caution" type="button" onclick="saveConfigNoRestart()">Merge and Save (CAUTION)</button>
|
||||
<button class="btn primary" type="button" onclick="saveConfig()">Save and restart</button>
|
||||
<button class="btn danger" type="button" onclick="saveConfigNoRestart()">Merge and Save (CAUTION)</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ class Handler:
|
||||
self._agent = agent
|
||||
self._app = app
|
||||
|
||||
# Dynamic theme CSS route
|
||||
self._app.add_url_rule("/css/theme.css", "dynamic_theme", self.dynamic_theme)
|
||||
|
||||
self._app.add_url_rule("/", "index", self.with_auth(self.index))
|
||||
self._app.add_url_rule("/ui", "ui", self.with_auth(self.ui))
|
||||
|
||||
@@ -309,6 +312,17 @@ class Handler:
|
||||
finally:
|
||||
_thread.start_new_thread(pwnagotchi.restart, (mode,))
|
||||
|
||||
# serve dynamic CSS with accent color from config
|
||||
def dynamic_theme(self):
|
||||
"""Generate CSS accent RGB variables from config [ui.web.theme] section"""
|
||||
# Get RGB values from already-loaded config, fallback to default green
|
||||
r = self._config.get("theme", {}).get("accent_r", 76)
|
||||
g = self._config.get("theme", {}).get("accent_g", 175)
|
||||
b = self._config.get("theme", {}).get("accent_b", 80)
|
||||
|
||||
css = f":root {{\n --accent: rgb({r}, {g}, {b});\n --accent-r: {r};\n --accent-g: {g};\n --accent-b: {b};\n}}"
|
||||
return Response(css, mimetype="text/css")
|
||||
|
||||
# serve the PNG file with the display image
|
||||
def ui(self):
|
||||
with web.frame_lock:
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
.unread {
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
background-color: rgba(76, 175, 80, 0.15);
|
||||
background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.15);
|
||||
border-left: 3px solid var(--accent);
|
||||
padding-left: calc(1rem - 3px);
|
||||
}
|
||||
|
||||
@@ -15,15 +15,14 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Colors - Dark Mode (Default) */
|
||||
/* Colors - Default */
|
||||
--bg-color: #121212;
|
||||
--card-bg: #1e1e1e;
|
||||
--text-main: #e0e0e0;
|
||||
--text-bright: #ffffff;
|
||||
--text-body: #bfbfbf;
|
||||
--text-muted: #888;
|
||||
--accent: #4caf50;
|
||||
--accent-hover: #66bb6a;
|
||||
--accent: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
|
||||
--danger: #ff5555;
|
||||
--danger-hover: #ff7777;
|
||||
--info: #4fc3f7;
|
||||
@@ -38,26 +37,10 @@
|
||||
/* Spacing & Effects */
|
||||
--transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 15px rgba(76, 175, 80, 0.1);
|
||||
--shadow-md: 0 4px 15px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1);
|
||||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
body.dark-mode {
|
||||
--bg-color: #121212;
|
||||
--card-bg: #1e1e1e;
|
||||
--text-main: #e0e0e0;
|
||||
--text-muted: #888;
|
||||
--accent: #4caf50;
|
||||
--accent-hover: #66bb6a;
|
||||
--danger: #ff5555;
|
||||
--danger-hover: #ff7777;
|
||||
--info: #4fc3f7;
|
||||
--border-color: #333;
|
||||
--bg-hover: #252525;
|
||||
--bg-secondary: #161616;
|
||||
--shadow-md: 0 4px 15px rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -97,7 +80,8 @@ a {
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-hover);
|
||||
color: var(--accent);
|
||||
filter: brightness(1.3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -183,7 +167,7 @@ strong, b {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
background: linear-gradient(to bottom, rgba(76, 175, 80, 0.05), transparent);
|
||||
background: linear-gradient(to bottom, rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05), transparent);
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
@@ -196,7 +180,7 @@ strong, b {
|
||||
line-height: 0.9;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
text-shadow: 0 0 15px rgba(76, 175, 80, 0.3);
|
||||
text-shadow: 0 0 15px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -243,14 +227,14 @@ strong, b {
|
||||
|
||||
.navbar-item a:hover {
|
||||
color: #ffffff;
|
||||
background-color: rgba(76, 175, 80, 0.08);
|
||||
background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.08);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navbar-item a.active {
|
||||
color: #4caf50;
|
||||
border-bottom-color: #4caf50;
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1);
|
||||
}
|
||||
|
||||
.navbar-icon {
|
||||
@@ -262,56 +246,74 @@ strong, b {
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
}
|
||||
|
||||
/* Navigation Icons - Default State */
|
||||
/* Navigation Icons - Using mask-image with currentColor via background-color */
|
||||
#home .navbar-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b0b0b0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9z'%3E%3C/path%3E%3Cpolyline points='9 22 9 12 15 12 15 22'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
-webkit-mask-image: url("../svg/home.svg");
|
||||
mask-image: url("../svg/home.svg");
|
||||
background-color: rgb(176,176,176);
|
||||
}
|
||||
|
||||
#inbox .navbar-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b0b0b0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z'%3E%3C/path%3E%3Cpolyline points='22 6 12 13 2 6'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
-webkit-mask-image: url("../svg/inbox.svg");
|
||||
mask-image: url("../svg/inbox.svg");
|
||||
background-color: rgb(176,176,176);
|
||||
}
|
||||
|
||||
#new .navbar-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b0b0b0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7'%3E%3C/path%3E%3Cpath d='M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z'%3E%3C/path%3E%3C/svg%3E");
|
||||
-webkit-mask-image: url("../svg/new.svg");
|
||||
mask-image: url("../svg/new.svg");
|
||||
background-color: rgb(176,176,176);
|
||||
}
|
||||
|
||||
#profile .navbar-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b0b0b0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'%3E%3C/path%3E%3Ccircle cx='12' cy='7' r='4'%3E%3C/circle%3E%3C/svg%3E");
|
||||
-webkit-mask-image: url("../svg/profile.svg");
|
||||
mask-image: url("../svg/profile.svg");
|
||||
background-color: rgb(176,176,176);
|
||||
}
|
||||
|
||||
#peers .navbar-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b0b0b0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2'%3E%3C/path%3E%3Ccircle cx='9' cy='7' r='4'%3E%3C/circle%3E%3Cpath d='M23 21v-2a4 4 0 0 0-3-3.87'%3E%3C/path%3E%3Cpath d='M16 3.13a4 4 0 0 1 0 7.75'%3E%3C/path%3E%3C/svg%3E");
|
||||
-webkit-mask-image: url("../svg/peers.svg");
|
||||
mask-image: url("../svg/peers.svg");
|
||||
background-color: rgb(176,176,176);
|
||||
}
|
||||
|
||||
#plugins .navbar-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b0b0b0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='1'%3E%3C/circle%3E%3Cpath d='M12 1v6m0 6v6'%3E%3C/path%3E%3Cpath d='M4.22 4.22l4.24 4.24m2.12 2.12l4.24 4.24'%3E%3C/path%3E%3Cpath d='M1 12h6m6 0h6'%3E%3C/path%3E%3Cpath d='M4.22 19.78l4.24-4.24m2.12-2.12l4.24-4.24'%3E%3C/path%3E%3C/svg%3E");
|
||||
-webkit-mask-image: url("../svg/plugins.svg");
|
||||
mask-image: url("../svg/plugins.svg");
|
||||
background-color: rgb(176,176,176);
|
||||
}
|
||||
|
||||
/* Active State - Icons turn green */
|
||||
/* Active state - Icons use accent color */
|
||||
#home.active .navbar-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234caf50' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9z'%3E%3C/path%3E%3Cpolyline points='9 22 9 12 15 12 15 22'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
|
||||
}
|
||||
|
||||
#inbox.active .navbar-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234caf50' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z'%3E%3C/path%3E%3Cpolyline points='22 6 12 13 2 6'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
|
||||
}
|
||||
|
||||
#new.active .navbar-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234caf50' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7'%3E%3C/path%3E%3Cpath d='M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z'%3E%3C/path%3E%3C/svg%3E");
|
||||
background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
|
||||
}
|
||||
|
||||
#profile.active .navbar-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234caf50' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'%3E%3C/path%3E%3Ccircle cx='12' cy='7' r='4'%3E%3C/circle%3E%3C/svg%3E");
|
||||
background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
|
||||
}
|
||||
|
||||
#peers.active .navbar-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234caf50' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2'%3E%3C/path%3E%3Ccircle cx='9' cy='7' r='4'%3E%3C/circle%3E%3Cpath d='M23 21v-2a4 4 0 0 0-3-3.87'%3E%3C/path%3E%3Cpath d='M16 3.13a4 4 0 0 1 0 7.75'%3E%3C/path%3E%3C/svg%3E");
|
||||
background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
|
||||
}
|
||||
|
||||
#plugins.active .navbar-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234caf50' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='1'%3E%3C/circle%3E%3Cpath d='M12 1v6m0 6v6'%3E%3C/path%3E%3Cpath d='M4.22 4.22l4.24 4.24m2.12 2.12l4.24 4.24'%3E%3C/path%3E%3Cpath d='M1 12h6m6 0h6'%3E%3C/path%3E%3Cpath d='M4.22 19.78l4.24-4.24m2.12-2.12l4.24-4.24'%3E%3C/path%3E%3C/svg%3E");
|
||||
background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -334,7 +336,7 @@ h1 {
|
||||
margin: 1.5rem 0 1rem 0;
|
||||
line-height: 0.9;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 0 15px rgba(76, 175, 80, 0.3);
|
||||
text-shadow: 0 0 15px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.3);
|
||||
font-weight: 400;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -521,21 +523,27 @@ textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 15px rgba(76, 175, 80, 0.1);
|
||||
box-shadow: 0 0 15px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1);
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
/* Select Box Styling */
|
||||
/* Select Box Styling - chevron-down.svg as mask with accent color */
|
||||
select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234caf50' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
background-size: 20px;
|
||||
-webkit-mask-image: url("../svg/chevron-down.svg");
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: right 10px center;
|
||||
-webkit-mask-size: 20px;
|
||||
mask-image: url("../svg/chevron-down.svg");
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: right 10px center;
|
||||
mask-size: 20px;
|
||||
padding-right: 36px;
|
||||
cursor: pointer;
|
||||
color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
|
||||
background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
|
||||
}
|
||||
|
||||
select::-ms-expand {
|
||||
@@ -574,7 +582,7 @@ input[type="reset"],
|
||||
font-family: var(--font-pixel);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 15px rgba(76, 175, 80, 0.4);
|
||||
box-shadow: 0 4px 15px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.4);
|
||||
text-transform: uppercase;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -591,7 +599,7 @@ input[type="reset"]:hover,
|
||||
.btn:hover {
|
||||
color: #000;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.5);
|
||||
box-shadow: 0 6px 20px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.5);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -602,7 +610,7 @@ input[type="button"]:active,
|
||||
input[type="reset"]:active,
|
||||
.btn:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.3);
|
||||
box-shadow: 0 2px 8px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.3);
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
@@ -699,7 +707,7 @@ input:checked + .slider {
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(1.2rem);
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.3);
|
||||
box-shadow: 0 0 8px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.3);
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
@@ -745,7 +753,7 @@ li {
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background-color: rgba(76, 175, 80, 0.05);
|
||||
background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05);
|
||||
border-left: 4px solid var(--accent);
|
||||
padding-left: calc(1rem - 4px);
|
||||
color: var(--text-bright);
|
||||
@@ -872,9 +880,9 @@ li {
|
||||
}
|
||||
|
||||
.badge.default {
|
||||
background: #2a3a2a;
|
||||
background: #262626;
|
||||
color: #888;
|
||||
border-color: #3a4a3a;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
@@ -944,7 +952,7 @@ li {
|
||||
|
||||
.toast-success {
|
||||
border-left-color: var(--accent);
|
||||
background: linear-gradient(135deg, rgba(76, 175, 80, 0.1), rgba(76, 175, 80, 0.05));
|
||||
background: linear-gradient(135deg, rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1), rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05));
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
@@ -981,9 +989,10 @@ li {
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: linear-gradient(135deg, rgba(76, 175, 80, 0.15), rgba(76, 175, 80, 0.08));
|
||||
background: linear-gradient(135deg, rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.15), rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.08));
|
||||
border-left-color: var(--accent);
|
||||
color: #66bb6a;
|
||||
color: var(--accent);
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
@@ -1087,7 +1096,8 @@ code {
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background-color: var(--accent-hover);
|
||||
background-color: var(--accent);
|
||||
filter: brightness(1.2);
|
||||
text-decoration: none;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
3
pwnagotchi/ui/web/static/svg/chevron-down.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 214 B |
4
pwnagotchi/ui/web/static/svg/home.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9z"></path>
|
||||
<polyline points="9 22 9 12 15 12 15 22"></polyline>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 290 B |
4
pwnagotchi/ui/web/static/svg/inbox.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
|
||||
<polyline points="22 6 12 13 2 6"></polyline>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 310 B |
4
pwnagotchi/ui/web/static/svg/new.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 321 B |
6
pwnagotchi/ui/web/static/svg/peers.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
3
pwnagotchi/ui/web/static/svg/plugins.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 1 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 339 B |
4
pwnagotchi/ui/web/static/svg/profile.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 269 B |
@@ -13,6 +13,7 @@
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/css" href="/css/style.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/css/theme.css" />
|
||||
{% if active_page == 'profile' %}
|
||||
<link rel="stylesheet" type="text/css" href="/css/profile.css" />
|
||||
{% elif active_page in ['inbox', 'new'] %}
|
||||
|
||||