added svg's for theme + configurable accent colors + bugfixes

This commit is contained in:
wiebesteven
2026-02-21 17:34:16 +01:00
parent 88f0517dde
commit 269cae95c2
17 changed files with 297 additions and 246 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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() {

View File

@@ -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 %}

View File

@@ -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:

View File

@@ -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);
}

View File

@@ -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);
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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'] %}