-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Time |
+ Level |
+ Message |
+
+
+
+
+
-
-
- |
- Time
- |
-
- Level
- |
-
- Message
- |
-
-
{% endblock %}
"""
class Logtail(plugins.Plugin):
- __author__ = '33197631+dadav@users.noreply.github.com'
- __version__ = '0.1.0'
- __license__ = 'GPL3'
- __description__ = 'This plugin tails the logfile.'
+ __author__ = "33197631+dadav@users.noreply.github.com"
+ __version__ = "0.1.0"
+ __license__ = "GPL3"
+ __description__ = "This plugin tails the logfile."
def __init__(self):
self.lock = threading.Lock()
@@ -258,15 +443,16 @@ class Logtail(plugins.Plugin):
return "Plugin not ready"
if not path or path == "/":
- return render_template_string(TEMPLATE)
+ return render_template_string(INDEX)
+
+ if path == "stream":
- if path == 'stream':
def generate():
- with open(self.config['main']['log']['path']) as f:
- yield ''.join(f.readlines()[-self.options.get('max-lines', 4096):])
+ with open(self.config["main"]["log"]["path"]) as f:
+ yield "".join(f.readlines()[-self.options.get("max-lines", 4096) :])
while True:
yield f.readline()
- return Response(generate(), mimetype='text/plain')
+ return Response(generate(), mimetype="text/plain")
abort(404)
diff --git a/pwnagotchi/plugins/default/session-stats.py b/pwnagotchi/plugins/default/session-stats.py
index 06ab1687..ff32908b 100644
--- a/pwnagotchi/plugins/default/session-stats.py
+++ b/pwnagotchi/plugins/default/session-stats.py
@@ -2,7 +2,7 @@ import os
import logging
import threading
from time import sleep
-from datetime import datetime,timedelta
+from datetime import datetime, timedelta
from pwnagotchi import plugins
from pwnagotchi.utils import StatusFile
from flask import render_template_string
@@ -12,252 +12,728 @@ TEMPLATE = """
{% extends "base.html" %}
{% set active_page = "plugins" %}
{% block title %}
- Session stats
+ Session Stats
{% endblock %}
{% block styles %}
{{ super() }}
-
-
{% endblock %}
{% block scripts %}
{{ super() }}
-
-
-
-
-
-
-
-
+
{% endblock %}
{% block script %}
- $(document).ready(function(){
- var ajaxDataRenderer = function(url, plot, options) {
- var ret = null;
- $.ajax({
- async: false,
- url: url,
- dataType:"json",
- success: function(data) {
- ret = data;
- }
- });
- return ret;
- };
+ const charts = {};
- function loadFiles(url, elm) {
- var data = ajaxDataRenderer(url);
- var x = document.getElementById(elm);
- $.each(data['files'], function( index, value ) {
- var option = document.createElement("option");
- option.text = value;
- x.add(option);
- });
- }
-
- function loadData(url, elm, title, fill) {
- var data = ajaxDataRenderer(url);
- var plot_os = $.jqplot(elm, data.values,{
- title: title,
- stackSeries: fill,
- seriesDefaults: {
- showMarker: !fill,
- fill: fill,
- fillAndStroke: fill
- },
- legend: {
- show: true,
- renderer: $.jqplot.EnhancedLegendRenderer,
- placement: 'outsideGrid',
- labels: data.labels,
- location: 's',
- rendererOptions: {
- numberRows: '2',
- },
- rowSpacing: '0px'
- },
- axes:{
- xaxis:{
- renderer:$.jqplot.DateAxisRenderer,
- tickOptions:{formatString:'%H:%M:%S'}
- },
- yaxis:{
- tickOptions:{formatString:'%.2f'}
- }
- },
- highlighter: {
- show: true,
- sizeAdjust: 7.5
- },
- cursor:{
- show: true,
- tooltipLocation:'sw'
+ async function fetchData(url) {
+ try {
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return await response.json();
+ } catch (error) {
+ console.error(`Failed to fetch ${url}:`, error);
+ return { values: [], labels: [] };
}
- }).replot({
- axes:{
- xaxis:{
- renderer:$.jqplot.DateAxisRenderer,
- tickOptions:{formatString:'%H:%M:%S'}
- },
- yaxis:{
- tickOptions:{formatString:'%.2f'}
- }
+ }
+
+ 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;
+
+ if (charts[elementId]) charts[elementId].destroy();
+
+ const allLabels = new Set();
+ data.values.forEach(values => {
+ values.forEach(([ts]) => allLabels.add(ts));
});
- }
+ const labels = Array.from(allLabels).sort();
- function loadSessionFiles() {
- loadFiles('/plugins/session-stats/session', 'session');
- $("#session").change(function() {
- loadSessionData();
+ const datasets = data.values.map((values, index) => {
+ const color = getChartColor(index);
+ const valueMap = Object.fromEntries(values);
+ const chartData = labels.map(ts => valueMap[ts] ?? null);
+
+ return {
+ label: data.labels[index],
+ data: chartData,
+ borderColor: color,
+ backgroundColor: getTransparentColor(color),
+ borderWidth: 2,
+ fill: true,
+ tension: 0.1,
+ pointRadius: 1,
+ pointHoverRadius: 4
+ };
});
+
+ let canvas = container.querySelector('canvas');
+ if (!canvas) {
+ canvas = document.createElement('canvas');
+ container.appendChild(canvas);
+ }
+
+ // Calculate required width based on number of data points
+ const dataPointCount = labels.length;
+ const minPixelsPerPoint = 50; // minimum pixels for each data point
+ const calculatedWidth = Math.max(container.clientWidth, dataPointCount * minPixelsPerPoint);
+
+ // Set canvas dimensions explicitly
+ canvas.width = calculatedWidth;
+ canvas.height = 250;
+
+ charts[elementId] = new Chart(canvas, {
+ type: 'line',
+ data: { labels, datasets },
+ options: {
+ responsive: false,
+ maintainAspectRatio: false,
+ plugins: {
+ title: {
+ display: true,
+ text: title,
+ font: { size: 16, family: 'var(--font-pixel)', weight: 'bold' },
+ color: '#fff',
+ padding: 20
+ },
+ legend: {
+ display: true,
+ position: 'bottom',
+ labels: {
+ color: '#fff',
+ font: { family: 'var(--font-main)', size: 12, weight: 'bold' },
+ padding: 15,
+ boxHeight: 4
+ }
+ },
+ tooltip: {
+ backgroundColor: '#000',
+ titleColor: '#fff',
+ bodyColor: '#fff',
+ borderColor: 'var(--accent)',
+ borderWidth: 1
+ }
+ },
+ scales: {
+ x: {
+ grid: {
+ color: '#333',
+ display: true
+ },
+ ticks: {
+ color: '#fff',
+ font: { family: 'var(--font-main)', size: 11, weight: 'bold' },
+ maxTicksLimit: 8
+ }
+ },
+ y: {
+ grid: {
+ color: '#333',
+ display: true
+ },
+ ticks: {
+ color: '#fff',
+ font: { family: 'var(--font-main)', size: 11, weight: 'bold' }
+ }
+ }
+ }
+ }
+ });
+
+ // Add hint text if it doesn't exist
+ if (!container.querySelector('.chart-hint')) {
+ const hint = document.createElement('div');
+ hint.className = 'chart-hint';
+ hint.textContent = 'Scroll left/right to view more data';
+ container.appendChild(hint);
+ }
}
- function loadSessionData() {
- var x = document.getElementById("session");
- var session = x.options[x.selectedIndex].text;
- loadData('/plugins/session-stats/os' + '?session=' + session, 'chart_os', 'OS', false)
- loadData('/plugins/session-stats/temp' + '?session=' + session, 'chart_temp', 'Temp', false)
- loadData('/plugins/session-stats/wifi' + '?session=' + session, 'chart_wifi', 'Wifi', true)
- loadData('/plugins/session-stats/duration' + '?session=' + session, 'chart_duration', 'Sleeping', true)
- loadData('/plugins/session-stats/reward' + '?session=' + session, 'chart_reward', 'Reward', false)
- loadData('/plugins/session-stats/epoch' + '?session=' + session, 'chart_epoch', 'Epochs', false)
+ function getChartColor(index) {
+ // 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() {
+ const sessionSelect = document.getElementById("session");
+ const session = sessionSelect?.options[sessionSelect.selectedIndex]?.text || 'Current';
+ const params = session === 'Current' ? '' : '?session=' + encodeURIComponent(session);
- loadSessionFiles();
- loadSessionData();
- setInterval(loadSessionData, 60000);
+ // Fetch summary stats
+ const summary = await fetchData('/plugins/session-stats/summary' + params);
+ if (summary.networks !== undefined) {
+ document.getElementById('stat_networks').textContent = summary.networks;
+ document.getElementById('stat_handshakes').textContent = summary.handshakes;
+ document.getElementById('stat_deauths').textContent = summary.deauths;
+ document.getElementById('stat_duration').textContent = summary.duration;
+ document.getElementById('stat_temp').textContent = summary.temp || '0Β°C';
+ document.getElementById('stat_mem').textContent = summary.mem || '0%';
+ document.getElementById('stat_cpu').textContent = summary.cpu || '0%';
+ }
+
+ // Fetch chart data
+ const chartConfigs = [
+ { endpoint: 'networks', id: 'chart_networks', title: 'Networks Captured' },
+ { endpoint: 'handshakes', id: 'chart_handshakes', title: 'Handshakes Captured' },
+ { endpoint: 'deauths', id: 'chart_deauths', title: 'Deauthentications Sent' },
+ { endpoint: 'temp', id: 'chart_temp', title: 'Temperature (Β°C)' },
+ { endpoint: 'mem', id: 'chart_mem', title: 'Memory Usage (%)' },
+ { endpoint: 'cpu', id: 'chart_cpu', title: 'CPU Load (%)' }
+ ];
+
+ for (const config of chartConfigs) {
+ const data = await fetchData('/plugins/session-stats/' + config.endpoint + params);
+ createChart(config.id, config.title, data);
+ }
+ }
+
+ async function loadSessionFiles() {
+ const data = await fetchData('/plugins/session-stats/sessions');
+ const select = document.getElementById("session");
+ data.files?.forEach(file => {
+ const option = document.createElement("option");
+ option.text = file;
+ select.appendChild(option);
+ });
+ select.addEventListener('change', updateStats);
+ }
+
+ document.addEventListener('DOMContentLoaded', () => {
+ loadSessionFiles();
+ updateStats();
+ setInterval(updateStats, 30000);
});
{% endblock %}
{% block content %}
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
Networks Captured
+
0
+
+
+
+
+
Session Duration
+
0s
+
+
+
+
+
+
+
{% endblock %}
"""
-class GhettoClock:
- def __init__(self):
- self.lock = threading.Lock()
- self._track = datetime.now()
- self._counter_thread = threading.Thread(target=self.counter)
- self._counter_thread.daemon = True
- self._counter_thread.start()
-
- def counter(self):
- while True:
- with self.lock:
- self._track += timedelta(seconds=1)
- sleep(1)
-
- def now(self):
- with self.lock:
- return self._track
-
class SessionStats(plugins.Plugin):
- __author__ = '33197631+dadav@users.noreply.github.com'
- __version__ = '0.1.0'
- __license__ = 'GPL3'
- __description__ = 'This plugin displays stats of the current session.'
+ __author__ = "33197631+dadav@users.noreply.github.com modified by wsvdmeer"
+ __version__ = "0.2.0"
+ __license__ = "GPL3"
+ __description__ = (
+ "Displays WiFi capture stats including networks, handshakes, and deauths."
+ )
+ DEFAULT_UPDATE_INTERVAL = 15 # RPi-friendly: 15 sec = 4 disk writes/min
+ DEFAULT_SAVE_PATH = (
+ "/home/pi/pwnagotchi/sessions/" # Standard location for user data
+ )
def __init__(self):
self.lock = threading.Lock()
self.options = dict()
self.stats = dict()
- self.clock = GhettoClock()
+ self.initialized = False
+ self.running = False
+ self.agent = None
+ self.realtime_thread = None
def on_loaded(self):
- """
- Gets called when the plugin gets loaded
- """
- # this has to happen in "loaded" because the options are not yet
- # available in the __init__
- os.makedirs(self.options['save_directory'], exist_ok=True)
- self.session_name = "stats_{}.json".format(self.clock.now().strftime("%Y_%m_%d_%H_%M"))
- self.session = StatusFile(os.path.join(self.options['save_directory'],
- self.session_name),
- data_format='json')
- logging.info("Session-stats plugin loaded.")
+ # Use default save path if not configured
+ save_dir = self.options.get("save_directory", self.DEFAULT_SAVE_PATH)
+ os.makedirs(save_dir, exist_ok=True)
+ self.session_name = "stats_{}.json".format(
+ datetime.now().strftime("%Y_%m_%d_%H_%M")
+ )
+ self.session = StatusFile(
+ os.path.join(save_dir, self.session_name),
+ data_format="json",
+ )
+
+ logging.info(f"Session-stats plugin loaded. Saving to: {save_dir}")
+
+ # Try to load historical data from the most recent previous session
+ try:
+ session_files = sorted(
+ [
+ f
+ for f in os.listdir(save_dir)
+ if f.startswith("stats_") and f.endswith(".json")
+ ]
+ )
+ if len(session_files) > 1: # More than just the current session
+ last_session_file = session_files[
+ -2
+ ] # Second to last is the previous session
+ last_session_path = os.path.join(save_dir, last_session_file)
+ last_session = StatusFile(last_session_path, data_format="json")
+ historical_data = last_session.data_field_or("data", default=dict())
+ if historical_data:
+ self.stats.update(historical_data)
+ logging.info(
+ f"Loaded {len(historical_data)} historical data points from {last_session_file}"
+ )
+ except Exception as e:
+ logging.warning(f"Could not load historical session data: {e}")
+
+ self.running = True
+ self.realtime_thread = threading.Thread(
+ target=self._realtime_loop, daemon=True, name="session-stats-realtime"
+ )
+ self.realtime_thread.start()
+ logging.info("Session-stats realtime collection thread started.")
+
+ def on_ready(self, agent):
+ """Called when the agent is ready - store reference for realtime stats"""
+ self.agent = agent
+ logging.debug("Session-stats agent reference captured")
+
+ def on_ui_setup(self, ui):
+ """Get agent reference from UI when UI is set up"""
+ if hasattr(ui, "_agent") and not self.agent:
+ self.agent = ui._agent
+ logging.debug("Session-stats agent reference captured from UI")
+
+ def on_unload(self):
+ self.running = False
+ if self.realtime_thread and self.realtime_thread.is_alive():
+ self.realtime_thread.join(timeout=5)
+ logging.info("Session-stats plugin unloaded.")
+
+ def _collect_stats(self):
+ """Collect current stats from agent (called both from realtime loop and epochs)"""
+ if not self.agent:
+ return None
+
+ try:
+ networks = len(self.agent._access_points)
+ handshakes = len(self.agent._handshakes)
+
+ stats_entry = {
+ "num_peers": networks,
+ "num_handshakes": handshakes,
+ "num_deauths": 0, # Will be updated if on_epoch is called
+ "temperature": 0, # Will be updated from system or epoch data
+ "mem_usage": 0,
+ "cpu_load": 0,
+ }
+ return stats_entry
+ except Exception as e:
+ logging.warning(f"Could not collect stats: {e}")
+ return None
+
+ def _realtime_loop(self):
+ """Background thread that collects stats periodically without waiting for epochs"""
+ update_interval = self.options.get(
+ "update_interval", self.DEFAULT_UPDATE_INTERVAL
+ )
+ agent_acquired = False
+
+ while self.running:
+ try:
+ sleep(update_interval)
+
+ if not self.agent:
+ if not agent_acquired:
+ logging.debug(
+ "Session-stats realtime loop: waiting for agent reference..."
+ )
+ continue
+
+ if not agent_acquired:
+ logging.info(
+ "Session-stats realtime loop: agent acquired, starting stats collection"
+ )
+ agent_acquired = True
+
+ with self.lock:
+ stats_entry = self._collect_stats()
+ if stats_entry:
+ # Use high-resolution timestamp
+ current_time = datetime.now()
+ timestamp = current_time.strftime("%H:%M:%S.%f")[:-3]
+
+ # Only update if this is new data or initialized
+ if not self.initialized:
+ self.stats[timestamp] = stats_entry
+ self.initialized = True
+ self.session.update(data={"data": self.stats})
+ logging.info(
+ f"Session-stats initialized (realtime): {stats_entry['num_peers']} networks, "
+ f"{stats_entry['num_handshakes']} handshakes"
+ )
+ else:
+ # Add to stats if data changed
+ last_stats = (
+ list(self.stats.values())[-1] if self.stats else None
+ )
+ if last_stats and (
+ stats_entry["num_peers"]
+ != last_stats.get("num_peers", 0)
+ or stats_entry["num_handshakes"]
+ != last_stats.get("num_handshakes", 0)
+ ):
+ self.stats[timestamp] = stats_entry
+ self.session.update(data={"data": self.stats})
+
+ except Exception as e:
+ logging.warning(f"Error in realtime stats loop: {e}")
def on_epoch(self, agent, epoch, epoch_data):
- """
- Save the epoch_data to self.stats
- """
- with self.lock:
- self.stats[self.clock.now().strftime("%H:%M:%S")] = epoch_data
- self.session.update(data={'data': self.stats})
+ # Store agent reference if not already set
+ if not self.agent:
+ self.agent = agent
+ logging.debug("Session-stats agent reference captured from epoch callback")
+ else:
+ self.agent = agent
- @staticmethod
- def extract_key_values(data, subkeys):
- result = dict()
- result['values'] = list()
- result['labels'] = subkeys
- for plot_key in subkeys:
- v = [ [ts,d[plot_key]] for ts, d in data.items()]
- result['values'].append(v)
- return result
+ with self.lock:
+ # Collect epoch-specific system metrics
+ stats_entry = {
+ "num_peers": len(agent._access_points),
+ "num_handshakes": len(agent._handshakes),
+ "num_deauths": epoch_data.get("num_deauths", 0),
+ "temperature": epoch_data.get("temperature", 0),
+ "mem_usage": epoch_data.get("mem_usage", 0),
+ "cpu_load": epoch_data.get("cpu_load", 0),
+ }
+
+ # Add epoch data with high-resolution timestamp
+ current_time = datetime.now()
+ timestamp = current_time.strftime("%H:%M:%S.%f")[:-3]
+ self.stats[timestamp] = stats_entry
+ self.session.update(data={"data": self.stats})
+
+ if not self.initialized:
+ self.initialized = True
+ logging.info(
+ f"Session-stats epoch update: {len(agent._access_points)} networks, "
+ f"{len(agent._handshakes)} handshakes"
+ )
def on_webhook(self, path, request):
if not path or path == "/":
return render_template_string(TEMPLATE)
- session_param = request.args.get('session')
-
- if path == "os":
- extract_keys = ['cpu_load','mem_usage',]
- elif path == "temp":
- extract_keys = ['temperature']
- elif path == "wifi":
- extract_keys = [
- 'missed_interactions',
- 'num_hops',
- 'num_peers',
- 'tot_bond',
- 'avg_bond',
- 'num_deauths',
- 'num_associations',
- 'num_handshakes',
- ]
- elif path == "duration":
- extract_keys = [
- 'duration_secs',
- 'slept_for_secs',
- ]
- elif path == "reward":
- extract_keys = [
- 'reward',
- ]
- elif path == "epoch":
- extract_keys = [
- 'active_for_epochs',
- ]
- elif path == "session":
- return jsonify({'files': os.listdir(self.options['save_directory'])})
+ session_param = request.args.get("session")
+ save_dir = self.options.get("save_directory", self.DEFAULT_SAVE_PATH)
with self.lock:
data = self.stats
- if session_param and session_param != 'Current':
- file_stats = StatusFile(os.path.join(self.options['save_directory'], session_param), data_format='json')
- data = file_stats.data_field_or('data', default=dict())
- return jsonify(SessionStats.extract_key_values(data, extract_keys))
+ if session_param and session_param != "Current":
+ file_stats = StatusFile(
+ os.path.join(save_dir, session_param),
+ data_format="json",
+ )
+ data = file_stats.data_field_or("data", default=dict())
+
+ if path == "summary":
+ total_networks = len(set(d.get("num_peers", 0) for d in data.values()))
+ total_handshakes = sum(d.get("num_handshakes", 0) for d in data.values())
+ total_deauths = sum(d.get("num_deauths", 0) for d in data.values())
+ duration = len(data) if data else 0
+ temp = max([d.get("temperature", 0) for d in data.values()], default=0)
+ mem = max([d.get("mem_usage", 0) for d in data.values()], default=0)
+ cpu = max([d.get("cpu_load", 0) for d in data.values()], default=0)
+
+ return jsonify(
+ {
+ "networks": total_networks,
+ "handshakes": total_handshakes,
+ "deauths": total_deauths,
+ "duration": f"{duration}s",
+ "temp": f"{temp:.1f}Β°C",
+ "mem": f"{mem:.1f}%",
+ "cpu": f"{cpu:.1f}%",
+ }
+ )
+
+ elif path == "networks":
+ return jsonify(self._extract_key_values(data, ["num_peers"]))
+ elif path == "handshakes":
+ return jsonify(self._extract_key_values(data, ["num_handshakes"]))
+ elif path == "deauths":
+ return jsonify(self._extract_key_values(data, ["num_deauths"]))
+ elif path == "temp":
+ return jsonify(self._extract_key_values(data, ["temperature"]))
+ elif path == "mem":
+ return jsonify(self._extract_key_values(data, ["mem_usage"]))
+ elif path == "cpu":
+ return jsonify(self._extract_key_values(data, ["cpu_load"]))
+ elif path == "sessions":
+ return jsonify({"files": os.listdir(save_dir)})
+
+ return jsonify({"error": "Unknown path"})
+
+ @staticmethod
+ def _extract_key_values(data, subkeys):
+ result = {"values": [], "labels": subkeys}
+ for plot_key in subkeys:
+ v = [[ts, d.get(plot_key, 0)] for ts, d in data.items()]
+ result["values"].append(v)
+ return result
diff --git a/pwnagotchi/plugins/default/webcfg.py b/pwnagotchi/plugins/default/webcfg.py
index 2bf018a6..939cfbfe 100644
--- a/pwnagotchi/plugins/default/webcfg.py
+++ b/pwnagotchi/plugins/default/webcfg.py
@@ -1,7 +1,7 @@
import logging
import json
import toml
-import _thread
+import threading # FIX B5: replaced _thread with threading
import pwnagotchi
from pwnagotchi import restart, plugins
from pwnagotchi.utils import save_config, merge_config
@@ -18,174 +18,315 @@ INDEX = """
{% block meta %}
-{% endblock %}
-
-{% block styles %}
+{% endblock %}{% block styles %}
{{ super() }}
{% endblock %}
{% block content %}
+
+
-
+
+
+
+
-
-
+
+
-
{% endblock %}
{% block script %}
@@ -204,7 +345,7 @@ INDEX = """
td = document.createElement("td");
td.setAttribute("data-label", "");
btnDel = document.createElement("Button");
- btnDel.innerHTML = "X";
+ btnDel.innerHTML = "";
btnDel.onclick = function(){ delRow(this);};
btnDel.className = "remove";
divDelBtn.appendChild(btnDel);
@@ -390,7 +531,7 @@ INDEX = """
td = document.createElement("td");
td.setAttribute("data-label", "");
btnDel = document.createElement("Button");
- btnDel.innerHTML = "X";
+ btnDel.innerHTML = "";
btnDel.onclick = function(){ delRow(this);};
btnDel.className = "remove";
divDelBtn.appendChild(btnDel);
@@ -427,7 +568,8 @@ INDEX = """
input.type = 'text';
input.value = '[]';
}else{
- input.type = typeof(json[key]);
+ var valType = typeof(json[key]);
+ input.type = valType === 'string' ? 'text' : valType;
input.value = json[key];
}
td.appendChild(input);
@@ -487,14 +629,14 @@ def serializer(obj):
class WebConfig(plugins.Plugin):
- __author__ = '33197631+dadav@users.noreply.github.com'
- __version__ = '1.0.0'
- __license__ = 'GPL3'
- __description__ = 'This plugin allows the user to make runtime changes.'
+ __author__ = "33197631+dadav@users.noreply.github.com modified by wsvdmeer"
+ __version__ = "1.0.0"
+ __license__ = "GPL3"
+ __description__ = "This plugin allows the user to make runtime changes."
def __init__(self):
self.ready = False
- self.mode = 'MANU'
+ self.mode = "MANU"
self._agent = None
def on_config_changed(self, config):
@@ -503,11 +645,11 @@ class WebConfig(plugins.Plugin):
def on_ready(self, agent):
self._agent = agent
- self.mode = 'MANU' if agent.mode == 'manual' else 'AUTO'
+ self.mode = "MANU" if agent.mode == "manual" else "AUTO"
def on_internet_available(self, agent):
self._agent = agent
- self.mode = 'MANU' if agent.mode == 'manual' else 'AUTO'
+ self.mode = "MANU" if agent.mode == "manual" else "AUTO"
def on_loaded(self):
"""
@@ -534,7 +676,7 @@ class WebConfig(plugins.Plugin):
if path == "save-config":
try:
save_config(request.get_json(), '/etc/pwnagotchi/config.toml') # test
- _thread.start_new_thread(restart, (self.mode,))
+ threading.Thread(target=restart, args=(self.mode,), daemon=True).start() # FIX B5
return "success"
except Exception as ex:
logging.error(ex)
@@ -542,13 +684,21 @@ class WebConfig(plugins.Plugin):
elif path == "merge-save-config":
try:
self.config = merge_config(request.get_json(), self.config)
- pwnagotchi.config = merge_config(request.get_json(), pwnagotchi.config)
+ pwnagotchi.config = merge_config(
+ request.get_json(), pwnagotchi.config
+ )
logging.debug("PWNAGOTCHI CONFIG:\n%s" % repr(pwnagotchi.config))
if self._agent:
- self._agent._config = merge_config(request.get_json(), self._agent._config)
- logging.debug(" Agent CONFIG:\n%s" % repr(self._agent._config))
+ self._agent._config = merge_config(
+ request.get_json(), self._agent._config
+ )
+ logging.debug(
+ " Agent CONFIG:\n%s" % repr(self._agent._config)
+ )
logging.debug(" Updated CONFIG:\n%s" % request.get_json())
- save_config(request.get_json(), '/etc/pwnagotchi/config.toml') # test
+ save_config(
+ request.get_json(), "/etc/pwnagotchi/config.toml"
+ ) # test
return "success"
except Exception as ex:
logging.error("[webcfg mergesave] %s" % ex)
diff --git a/pwnagotchi/ui/web/handler.py b/pwnagotchi/ui/web/handler.py
index 6e75cb02..3a2189a8 100644
--- a/pwnagotchi/ui/web/handler.py
+++ b/pwnagotchi/ui/web/handler.py
@@ -1,7 +1,7 @@
import logging
import os
import base64
-import _thread
+import threading # FIX B5: replaced _thread with threading
import secrets
import json
from functools import wraps
@@ -9,8 +9,8 @@ from functools import wraps
import flask
# https://stackoverflow.com/questions/14888799/disable-console-messages-in-flask-server
-logging.getLogger('werkzeug').setLevel(logging.ERROR)
-os.environ['WERKZEUG_RUN_MAIN'] = 'false'
+logging.getLogger("werkzeug").setLevel(logging.ERROR)
+os.environ["WERKZEUG_RUN_MAIN"] = "false"
import pwnagotchi
import pwnagotchi.grid as grid
@@ -32,21 +32,45 @@ class Handler:
self._agent = agent
self._app = app
- self._app.add_url_rule('/', 'index', self.with_auth(self.index))
- self._app.add_url_rule('/ui', 'ui', self.with_auth(self.ui))
+ # Dynamic theme CSS route
+ self._app.add_url_rule("/css/theme.css", "dynamic_theme", self.dynamic_theme)
- self._app.add_url_rule('/shutdown', 'shutdown', self.with_auth(self.shutdown), methods=['POST'])
- self._app.add_url_rule('/reboot', 'reboot', self.with_auth(self.reboot), methods=['POST'])
- self._app.add_url_rule('/restart', 'restart', self.with_auth(self.restart), methods=['POST'])
+ self._app.add_url_rule("/", "index", self.with_auth(self.index))
+ self._app.add_url_rule("/ui", "ui", self.with_auth(self.ui))
+
+ self._app.add_url_rule(
+ "/shutdown", "shutdown", self.with_auth(self.shutdown), methods=["POST"]
+ )
+ self._app.add_url_rule(
+ "/reboot", "reboot", self.with_auth(self.reboot), methods=["POST"]
+ )
+ self._app.add_url_rule(
+ "/restart", "restart", self.with_auth(self.restart), methods=["POST"]
+ )
# inbox
- self._app.add_url_rule('/inbox', 'inbox', self.with_auth(self.inbox))
- self._app.add_url_rule('/inbox/profile', 'inbox_profile', self.with_auth(self.inbox_profile))
- self._app.add_url_rule('/inbox/peers', 'inbox_peers', self.with_auth(self.inbox_peers))
- self._app.add_url_rule('/inbox/
', 'show_message', self.with_auth(self.show_message))
- self._app.add_url_rule('/inbox//', 'mark_message', self.with_auth(self.mark_message))
- self._app.add_url_rule('/inbox/new', 'new_message', self.with_auth(self.new_message))
- self._app.add_url_rule('/inbox/send', 'send_message', self.with_auth(self.send_message), methods=['POST'])
+ self._app.add_url_rule("/inbox", "inbox", self.with_auth(self.inbox))
+ self._app.add_url_rule(
+ "/inbox/profile", "inbox_profile", self.with_auth(self.inbox_profile)
+ )
+ self._app.add_url_rule(
+ "/inbox/peers", "inbox_peers", self.with_auth(self.inbox_peers)
+ )
+ self._app.add_url_rule(
+ "/inbox/", "show_message", self.with_auth(self.show_message)
+ )
+ self._app.add_url_rule(
+ "/inbox//", "mark_message", self.with_auth(self.mark_message)
+ )
+ self._app.add_url_rule(
+ "/inbox/new", "new_message", self.with_auth(self.new_message)
+ )
+ self._app.add_url_rule(
+ "/inbox/send",
+ "send_message",
+ self.with_auth(self.send_message),
+ methods=["POST"],
+ )
# plugins
plugins_with_auth = self.with_auth(self.plugins)
@@ -58,52 +82,57 @@ class Handler:
def _check_creds(self, u, p):
# trying to be timing attack safe
- return secrets.compare_digest(u, self._config['username']) and \
- secrets.compare_digest(p, self._config['password'])
+ return secrets.compare_digest(
+ u, self._config["username"]
+ ) and secrets.compare_digest(p, self._config["password"])
def with_auth(self, f):
@wraps(f)
def wrapper(*args, **kwargs):
- if not self._config['auth']:
+ if not self._config["auth"]:
return f(*args, **kwargs)
else:
auth = request.authorization
- if not auth or not auth.username or not auth.password or not self._check_creds(auth.username,
- auth.password):
- return Response('Unauthorized', 401, {'WWW-Authenticate': 'Basic realm="Unauthorized"'})
+ if (
+ not auth
+ or not auth.username
+ or not auth.password
+ or not self._check_creds(auth.username, auth.password)
+ ):
+ return Response(
+ "Unauthorized",
+ 401,
+ {"WWW-Authenticate": 'Basic realm="Unauthorized"'},
+ )
return f(*args, **kwargs)
return wrapper
def index(self):
- return render_template('index.html',
- title=pwnagotchi.name(),
- other_mode='AUTO' if self._agent.mode == 'manual' else 'MANU',
- fingerprint=self._agent.fingerprint())
+ return render_template(
+ "index.html",
+ title=pwnagotchi.name(),
+ other_mode="AUTO" if self._agent.mode == "manual" else "MANU",
+ fingerprint=self._agent.fingerprint(),
+ )
def inbox(self):
page = request.args.get("p", default=1, type=int)
- inbox = {
- "pages": 1,
- "records": 0,
- "messages": []
- }
+ inbox = {"pages": 1, "records": 0, "messages": []}
error = None
try:
if not grid.is_connected():
- raise Exception('not connected')
+ raise Exception("not connected")
inbox = grid.inbox(page, with_pager=True)
except Exception as e:
- logging.exception('error while reading pwnmail inbox')
+ logging.exception("error while reading pwnmail inbox")
error = str(e)
- return render_template('inbox.html',
- name=pwnagotchi.name(),
- page=page,
- error=error,
- inbox=inbox)
+ return render_template(
+ "inbox.html", name=pwnagotchi.name(), page=page, error=error, inbox=inbox
+ )
def inbox_profile(self):
data = {}
@@ -112,14 +141,16 @@ class Handler:
try:
data = grid.get_advertisement_data()
except Exception as e:
- logging.exception('error while reading pwngrid data')
+ logging.exception("error while reading pwngrid data")
error = str(e)
- return render_template('profile.html',
- name=pwnagotchi.name(),
- fingerprint=self._agent.fingerprint(),
- data=json.dumps(data, indent=2),
- error=error)
+ return render_template(
+ "profile.html",
+ name=pwnagotchi.name(),
+ fingerprint=self._agent.fingerprint(),
+ data=json.dumps(data, indent=2),
+ error=error,
+ )
def inbox_peers(self):
peers = {}
@@ -128,13 +159,12 @@ class Handler:
try:
peers = grid.memory()
except Exception as e:
- logging.exception('error while reading pwngrid peers')
+ logging.exception("error while reading pwngrid peers")
error = str(e)
- return render_template('peers.html',
- name=pwnagotchi.name(),
- peers=peers,
- error=error)
+ return render_template(
+ "peers.html", name=pwnagotchi.name(), peers=peers, error=error
+ )
def show_message(self, id):
message = {}
@@ -142,23 +172,22 @@ class Handler:
try:
if not grid.is_connected():
- raise Exception('not connected')
+ raise Exception("not connected")
message = grid.inbox_message(id)
- if message['data']:
- message['data'] = base64.b64decode(message['data']).decode("utf-8")
+ if message["data"]:
+ message["data"] = base64.b64decode(message["data"]).decode("utf-8")
except Exception as e:
- logging.exception('error while reading pwnmail message %d' % int(id))
+ logging.exception("error while reading pwnmail message %d" % int(id))
error = str(e)
- return render_template('message.html',
- name=pwnagotchi.name(),
- error=error,
- message=message)
+ return render_template(
+ "message.html", name=pwnagotchi.name(), error=error, message=message
+ )
def new_message(self):
to = request.args.get("to", default="")
- return render_template('new_message.html', to=to)
+ return render_template("new_message.html", to=to)
def send_message(self):
to = request.form["to"]
@@ -167,7 +196,7 @@ class Handler:
try:
if not grid.is_connected():
- raise Exception('not connected')
+ raise Exception("not connected")
grid.send_message(to, message)
except Exception as e:
@@ -185,18 +214,41 @@ class Handler:
def plugins(self, name, subpath):
if name is None:
- return render_template('plugins.html', loaded=plugins.loaded, database=plugins.database)
+ # Determine which plugins are from the default folder
+ default_plugins = set()
+ default_path = os.path.join(
+ os.path.dirname(os.path.realpath(plugins.__file__)), "default"
+ )
+ for plugin_name, plugin_path in plugins.database.items():
+ if plugin_path.startswith(default_path):
+ default_plugins.add(plugin_name)
+ return render_template(
+ "plugins.html",
+ loaded=plugins.loaded,
+ database=plugins.database,
+ default_plugins=default_plugins,
+ )
- if name == 'toggle' and request.method == 'POST':
- checked = True if 'enabled' in request.form else False
- return 'success' if plugins.toggle_plugin(request.form['plugin'], checked) else 'failed'
+ if name == "toggle" and request.method == "POST":
+ checked = True if "enabled" in request.form else False
+ return (
+ "success"
+ if plugins.toggle_plugin(request.form["plugin"], checked)
+ else "failed"
+ )
- if name == 'upgrade' and request.method == 'POST':
+ if name == "upgrade" and request.method == "POST":
logging.info(f"Upgrading plugin: {request.form['plugin']}")
- os.system(f"pwnagotchi plugins update && pwnagotchi plugins upgrade {request.form['plugin']}")
+ os.system(
+ f"pwnagotchi plugins update && pwnagotchi plugins upgrade {request.form['plugin']}"
+ )
return redirect("/plugins")
- if name in plugins.loaded and plugins.loaded[name] is not None and hasattr(plugins.loaded[name], 'on_webhook'):
+ if (
+ name in plugins.loaded
+ and plugins.loaded[name] is not None
+ and hasattr(plugins.loaded[name], "on_webhook")
+ ):
try:
return plugins.loaded[name].on_webhook(subpath, request)
except Exception:
@@ -207,32 +259,60 @@ class Handler:
# serve a message and shuts down the unit
def shutdown(self):
try:
- return render_template('status.html', title=pwnagotchi.name(), go_back_after=60,
- message='Shutting down ...')
+ return render_template(
+ "status.html",
+ title=pwnagotchi.name(),
+ go_back_after=60,
+ message="Shutting down ...",
+ )
finally:
- _thread.start_new_thread(pwnagotchi.shutdown, ())
+ # FIX B5: replaced _thread.start_new_thread with threading.Thread
+ threading.Thread(target=pwnagotchi.shutdown, daemon=True).start()
# serve a message and reboot the unit
def reboot(self):
- try:
- return render_template('status.html', title=pwnagotchi.name(), go_back_after=60,
- message='Rebooting ...')
- finally:
- _thread.start_new_thread(pwnagotchi.reboot, ())
+ try:
+ return render_template(
+ "status.html",
+ title=pwnagotchi.name(),
+ go_back_after=60,
+ message="Rebooting ...",
+ )
+ finally:
+ # FIX B5: replaced _thread.start_new_thread with threading.Thread
+ threading.Thread(target=pwnagotchi.reboot, daemon=True).start()
# serve a message and restart the unit in the other mode
def restart(self):
- mode = request.form['mode']
- if mode not in ('AUTO', 'MANU'):
- mode = 'MANU'
+ mode = request.form["mode"]
+ if mode not in ("AUTO", "MANU"):
+ mode = "MANU"
try:
- return render_template('status.html', title=pwnagotchi.name(), go_back_after=30,
- message='Restarting in %s mode ...' % mode)
+ return render_template(
+ "status.html",
+ title=pwnagotchi.name(),
+ go_back_after=30,
+ message="Restarting in %s mode ..." % mode,
+ )
finally:
- _thread.start_new_thread(pwnagotchi.restart, (mode,))
+ # FIX B5: replaced _thread.start_new_thread with threading.Thread
+ threading.Thread(
+ target=pwnagotchi.restart, args=(mode,), daemon=True
+ ).start()
+
+ # 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:
- return send_file(web.frame_path, mimetype='image/png')
+ return send_file(web.frame_path, mimetype="image/png")
diff --git a/pwnagotchi/ui/web/static/css/style.css b/pwnagotchi/ui/web/static/css/style.css
index 1ed13fa5..4f0e73e8 100644
--- a/pwnagotchi/ui/web/static/css/style.css
+++ b/pwnagotchi/ui/web/static/css/style.css
@@ -1,82 +1,1415 @@
+/* ============================================
+ Pwnagotchi Web UI - Modern Dark Theme
+ Color Scheme: Terminal Green Accent
+ ============================================ */
+
+/* VT323 Font - Load locally */
+@font-face {
+ font-family: 'VT323';
+ src: url('../fonts/VT323-Regular.woff2') format('woff2'),
+ url('../fonts/VT323-Regular.woff') format('woff'),
+ url('../fonts/VT323-Regular.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
+
+:root {
+ /* Colors - Default */
+ --bg-color: #121212;
+ --card-bg: #1e1e1e;
+ --text-main: #e0e0e0;
+ --text-bright: #ffffff;
+ --text-body: #bfbfbf;
+ --text-muted: #888;
+ --accent: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
+ --danger: #ff5555;
+ --danger-hover: #ff7777;
+ --info: #4fc3f7;
+ --border-color: #333;
+ --bg-hover: #252525;
+ --bg-secondary: #161616;
+
+ /* Fonts */
+ --font-main: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ --font-pixel: "VT323", monospace;
+
+ /* 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(var(--accent-r), var(--accent-g), var(--accent-b), 0.1);
+ --shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.4);
+}
+
+* {
+ box-sizing: border-box;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ height: 100vh;
+ font-family: var(--font-main);
+ font-size: 0.95rem;
+ line-height: 1.6;
+ color: var(--text-main);
+ background-color: var(--bg-color);
+ overflow-x: hidden;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ image-rendering: pixelated;
+ image-rendering: -moz-crisp-edges;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ transition: background-color 0.3s, color 0.3s;
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+ transition: color 0.2s;
+}
+
+a:hover {
+ color: var(--accent);
+ filter: brightness(1.3);
+}
+
+/* ============================================
+ Global Text Wrapping
+ ============================================ */
+
+h1, h2, h3, h4, h5, h6 {
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+}
+
+strong, b {
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ word-break: break-word;
+ display: inline;
+}
+
+.messagebody-meta strong,
+.messagebody-meta b {
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ word-break: break-word;
+}
+
+/* ============================================
+ Scrollbars
+ ============================================ */
+
+/* Firefox */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: #444 transparent;
+}
+
+/* Webkit (Chrome, Safari, Edge) */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #444;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #666;
+}
+
+/* ============================================
+ Layout Structure
+ ============================================ */
+
+.page-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+}
+
+.page-content {
+ flex: 1;
+ padding: 16px;
+ width: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ background-color: var(--bg-color);
+}
+
+.page-footer {
+ margin-top: auto;
+ background-color: #0d0d0d;
+ padding: 0;
+ overflow: hidden;
+}
+
+.page-header {
+ padding: 40px 20px;
+ text-align: center;
+ margin-bottom: 40px;
+ background: linear-gradient(to bottom, rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05), transparent);
+ border-bottom: 1px solid #2a2a2a;
+}
+
+.page-header h1 {
+ font-family: var(--font-pixel);
+ font-weight: 400;
+ font-size: 3.5rem;
+ color: #fff;
+ margin: 0;
+ line-height: 0.9;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ text-shadow: 0 0 15px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.3);
+}
+
+/* ============================================
+ Navigation Bar
+ ============================================ */
+
+.navbar {
+ display: flex;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ gap: 0;
+ background-color: #0d0d0d;
+ border-top: 1px solid #333;
+}
+
+.navbar-item {
+ flex: 1;
+ text-align: center;
+ margin-bottom: 0;
+}
+
+.navbar-item a {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ color: #b0b0b0;
+ font-size: 0.65rem;
+ border-bottom: 4px solid transparent;
+ transition: var(--transition);
+ white-space: nowrap;
+ height: 100%;
+ flex: 1;
+ min-height: 60px;
+ font-weight: 400;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ gap: 0.25rem;
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+}
+
+.navbar-item a:hover {
+ color: #ffffff;
+ background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.08);
+ text-decoration: none;
+}
+
+.navbar-item a.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+ background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1);
+}
+
+.navbar-icon {
+ display: flex;
+ width: 1.5rem;
+ height: 1.5rem;
+ align-items: center;
+ justify-content: center;
+ 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 - Using mask-image with currentColor via background-color */
+#home .navbar-icon {
+ -webkit-mask-image: url("../svg/home.svg");
+ mask-image: url("../svg/home.svg");
+ background-color: rgb(176,176,176);
+}
+
+#inbox .navbar-icon {
+ -webkit-mask-image: url("../svg/inbox.svg");
+ mask-image: url("../svg/inbox.svg");
+ background-color: rgb(176,176,176);
+}
+
+#new .navbar-icon {
+ -webkit-mask-image: url("../svg/new.svg");
+ mask-image: url("../svg/new.svg");
+ background-color: rgb(176,176,176);
+}
+
+#profile .navbar-icon {
+ -webkit-mask-image: url("../svg/profile.svg");
+ mask-image: url("../svg/profile.svg");
+ background-color: rgb(176,176,176);
+}
+
+#peers .navbar-icon {
+ -webkit-mask-image: url("../svg/peers.svg");
+ mask-image: url("../svg/peers.svg");
+ background-color: rgb(176,176,176);
+}
+
+#plugins .navbar-icon {
+ -webkit-mask-image: url("../svg/plugins.svg");
+ mask-image: url("../svg/plugins.svg");
+ background-color: rgb(176,176,176);
+}
+
+/* Active state - Icons use accent color */
+#home.active .navbar-icon {
+ background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
+}
+
+#inbox.active .navbar-icon {
+ background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
+}
+
+#new.active .navbar-icon {
+ background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
+}
+
+#profile.active .navbar-icon {
+ background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
+}
+
+#peers.active .navbar-icon {
+ background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
+}
+
+#plugins.active .navbar-icon {
+ background-color: rgb(var(--accent-r), var(--accent-g), var(--accent-b));
+}
+
+/* ============================================
+ Typography
+ ============================================ */
+
+h1, h2, h3, h4, h5, h6 {
+ margin-top: 0;
+ margin-bottom: 0.5rem;
+ font-weight: 400;
+ line-height: 1.25;
+ color: var(--text-main);
+}
+
+h1 {
+ font-size: 3.5rem;
+ font-family: var(--font-pixel);
+ letter-spacing: 2px;
+ color: #ffffff;
+ margin: 1.5rem 0 1rem 0;
+ line-height: 0.9;
+ text-transform: uppercase;
+ 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;
+ text-rendering: geometricPrecision;
+}
+
+h2 {
+ font-size: 1.8rem;
+ color: var(--accent);
+ font-family: var(--font-pixel);
+ font-weight: 400;
+ letter-spacing: 1px;
+ margin: 1.25rem 0 0.75rem 0;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: geometricPrecision;
+}
+
+h3 {
+ font-size: 1.4rem;
+ color: var(--accent);
+ font-family: var(--font-pixel);
+ font-weight: 400;
+ letter-spacing: 1px;
+ margin: 1rem 0 0.5rem 0;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: geometricPrecision;
+}
+
+h4 {
+ font-family: var(--font-pixel);
+ font-size: 1.2rem;
+ color: var(--text-main);
+ font-weight: 400;
+ margin: 1rem 0 0.75rem 0;
+ letter-spacing: 0.5px;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: geometricPrecision;
+}
+
+h5 {
+ font-family: var(--font-pixel);
+ font-size: 1.1rem;
+ color: var(--text-main);
+ font-weight: 400;
+ margin: 0.75rem 0 0.5rem 0;
+ letter-spacing: 0.5px;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: geometricPrecision;
+}
+
+h6 {
+ font-family: var(--font-pixel);
+ font-size: 1rem;
+ color: var(--text-main);
+ font-weight: 400;
+ margin: 0.75rem 0 0.5rem 0;
+ letter-spacing: 0.5px;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: geometricPrecision;
+}
+
+p {
+ margin-top: 0;
+ margin-bottom: 1rem;
+ color: var(--text-body);
+ line-height: 1.6;
+ font-size: 0.9rem;
+}
+
+p::first-letter {
+ text-transform: uppercase;
+}
+
+small {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+/* ============================================
+ Forms
+ ============================================ */
+
+form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.control-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ justify-content: flex-start;
+ align-items: stretch;
+}
+
+.control-buttons form {
+ flex: 1 1 calc(33% - 0.5rem);
+ min-width: 140px;
+ display: flex;
+ margin: 0;
+ gap: 0;
+}
+
+.control-buttons button {
+ flex: 1;
+ min-width: 100px;
+ margin: 0;
+ font-size: 0.95rem;
+ padding: 10px 16px;
+}
+
+@media (max-width: 768px) {
+ .control-buttons form {
+ flex: 1 1 calc(50% - 0.375rem);
+ min-width: 100px;
+ }
+
+ .control-buttons button {
+ font-size: 0.9rem;
+ padding: 8px 12px;
+ }
+}
+
+@media (max-width: 480px) {
+ .control-buttons form {
+ flex: 1 1 calc(50% - 0.375rem);
+ min-width: 80px;
+ }
+
+ .control-buttons button {
+ font-size: 0.85rem;
+ padding: 8px 12px;
+ }
+}
+
+label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: 600;
+ color: var(--accent);
+ text-transform: uppercase;
+ font-size: 0.9rem;
+ letter-spacing: 0.5px;
+ font-family: var(--font-pixel);
+ transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+label:hover {
+ color: var(--accent);
+}
+
+input[type="text"],
+input[type="email"],
+input[type="password"],
+input[type="number"],
+textarea,
+select {
+ width: 100%;
+ padding: 12px 16px;
+ font-size: 0.95rem;
+ font-family: var(--font-main);
+ line-height: 1.5;
+ color: var(--text-main);
+ background-color: #1a1a1a;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: geometricPrecision;
+}
+
+input[type="text"]:focus,
+input[type="email"]:focus,
+input[type="password"]:focus,
+input[type="number"]:focus,
+textarea:focus,
+select:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 15px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1);
+ background-color: #1e1e1e;
+}
+
+/* Select Box Styling - chevron-down.svg as mask with accent color */
+select {
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ -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 {
+ display: none;
+}
+
+/* Placeholder text styling */
+input::placeholder,
+textarea::placeholder {
+ color: var(--text-muted);
+}
+
+/* Textarea improvements */
+textarea {
+ font-family: var(--font-main);
+ resize: vertical;
+ min-height: 6rem;
+}
+
+/* ============================================
+ Buttons
+ ============================================ */
+
+button,
+input[type="submit"],
+input[type="button"],
+input[type="reset"],
+.btn {
+ width: auto;
+ min-width: 120px;
+ padding: 12px 28px;
+ background: var(--accent);
+ color: #000;
+ border: none;
+ border-radius: 50px;
+ font-family: var(--font-pixel);
+ font-size: 1.2rem;
+ cursor: pointer;
+ 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;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: geometricPrecision;
+ font-weight: 400;
+ letter-spacing: 0.5px;
+}
+
+button:hover,
+input[type="submit"]:hover,
+input[type="button"]:hover,
+input[type="reset"]:hover,
+.btn:hover {
+ color: #000;
+ background-color: #ffffff;
+ 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;
+}
+
+button:active,
+input[type="submit"]:active,
+input[type="button"]:active,
+input[type="reset"]:active,
+.btn:active {
+ transform: translateY(0) scale(0.98);
+ box-shadow: 0 2px 8px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.3);
+}
+
+button:disabled,
+input[type="submit"]:disabled,
+input[type="button"]:disabled,
+input[type="reset"]:disabled,
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.btn.danger {
+ background-color: var(--danger);
+ box-shadow: 0 4px 12px rgba(255, 85, 85, 0.3);
+ color: #fff;
+}
+
+.btn.danger:hover {
+ background-color: var(--danger-hover);
+ box-shadow: 0 6px 16px rgba(255, 85, 85, 0.4);
+}
+
+.btn.info {
+ background-color: var(--info);
+ color: #000;
+ box-shadow: 0 4px 12px rgba(79, 195, 247, 0.3);
+}
+
+.btn.info:hover {
+ background-color: #29b6f6;
+ box-shadow: 0 6px 16px rgba(79, 195, 247, 0.4);
+}
+
+.btn.secondary {
+ background-color: var(--text-muted);
+ color: #000;
+ box-shadow: 0 4px 12px rgba(136, 136, 136, 0.3);
+}
+
+.btn.secondary:hover {
+ background-color: #aaa;
+ box-shadow: 0 6px 16px rgba(136, 136, 136, 0.4);
+}
+
+
+/* ============================================
+ Toggle Switch (Checkbox)
+ ============================================ */
+
+.switch {
+ position: relative;
+ display: inline-block;
+ width: 2.8rem;
+ height: 1.6rem;
+}
+
+.switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #333;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ border-radius: 2rem;
+ border: 1px solid #4a4a4a;
+}
+
+.slider:before {
+ position: absolute;
+ content: "";
+ height: 1.1rem;
+ width: 1.1rem;
+ left: 0.2rem;
+ bottom: 0.2rem;
+ background-color: var(--text-main);
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ border-radius: 50%;
+}
+
+input:checked + .slider {
+ background-color: var(--accent);
+ border-color: var(--accent);
+}
+
+input:checked + .slider:before {
+ transform: translateX(1.2rem);
+ background-color: #ffffff;
+ box-shadow: 0 0 8px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.3);
+}
+
+.switch-label {
+ display: inline-block;
+ margin-left: 0.5rem;
+ font-weight: 500;
+}
+
+/* ============================================
+ Lists
+ ============================================ */
+
+ul, ol {
+ margin-top: 0;
+ margin-bottom: 1rem;
+ padding-left: 1.5rem;
+}
+
+li {
+ margin-bottom: 0.5rem;
+}
+
+.list-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ margin-bottom: 1rem;
+ border: 1px solid #333;
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.list-item {
+ padding: 0.75rem 1rem;
+ background-color: var(--card-bg);
+ border-bottom: 1px solid #333;
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+ color: var(--text-body);
+}
+
+.list-item:last-child {
+ border-bottom: none;
+}
+
+.list-item:hover {
+ 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);
+}
+
+.list-item a {
+ display: block;
+ color: inherit;
+ text-decoration: none;
+}
+
+.list-item a:hover {
+ text-decoration: none;
+}
+
+.list-item-primary {
+ font-weight: 500;
+ margin-bottom: 0.25rem;
+ color: var(--accent);
+ font-size: 0.95rem;
+}
+
+.list-item-secondary {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ line-height: 1.4;
+}
+
+/* ============================================
+ Cards & Boxes
+ ============================================ */
+
+.card {
+ background-color: var(--card-bg);
+ border: 1px solid #2a2a2a;
+ border-radius: 16px;
+ padding: 16px;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ transition: all 0.3s ease;
+ box-shadow: var(--shadow-md);
+}
+
+.card:hover {
+ border-color: var(--accent);
+ box-shadow: 0 10px 30px rgba(0,0,0,0.4);
+ transform: translateY(-5px);
+}
+
+.card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 5px;
+ font-weight: 400;
+ color: var(--accent);
+ font-family: var(--font-pixel);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ font-size: 1.4rem;
+}
+
+.card-body {
+ font-size: 0.9rem;
+ color: var(--text-main);
+ line-height: 1.5;
+ display: -webkit-box;
+ -webkit-line-clamp: 999;
+ line-clamp: 999;
+ -webkit-box-orient: vertical;
+ flex-grow: 1;
+ background-color: var(--card-bg);
+}
+
+.card-body::first-letter {
+ text-transform: uppercase;
+}
+
+.card-footer {
+ margin-top: auto;
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+/* Plugin-specific button styles */
+.plugin-upgrade-btn {
+ width: 100%;
+}
+
+.plugin-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.plugins-box > div:not(.tooltip) {
+ margin-top: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+/* ============================================
+ Badges
+ ============================================ */
+
+.badge {
+ background: #2a2a2a;
+ font-size: 0.65rem;
+ padding: 2px 6px;
+ border-radius: 4px;
+ color: #777;
+ font-family: var(--font-pixel);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ border: 1px solid #444;
+}
+
+.badge.version {
+ flex-shrink: 0;
+}
+
+.badge.default {
+ background: #262626;
+ color: #888;
+ border-color: #333;
+}
+
+.tooltip {
+ position: relative;
+ display: inline-block;
+ cursor: help;
+}
+
+.tooltip .tooltiptext {
+ visibility: hidden;
+ width: 180px;
+ background-color: rgba(0, 0, 0, 0.8);
+ color: #fff;
+ text-align: center;
+ border-radius: 4px;
+ padding: 0.4rem 0.6rem;
+ position: absolute;
+ z-index: 1;
+ top: 100%;
+ left: 50%;
+ margin-left: -90px;
+ margin-top: 0.4rem;
+ font-size: 0.75rem;
+ opacity: 0;
+ transition: opacity 0.3s;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+.tooltip:hover .tooltiptext {
+ visibility: visible;
+ opacity: 1;
+}
+
+/* ============================================
+ Messages & Alerts
+ ============================================ */
+
+/* Toast Notifications */
+.toast {
+ position: fixed;
+ bottom: 2rem;
+ left: 50%;
+ transform: translateX(-50%) translateY(120px);
+ background-color: #1e1e1e;
+ color: var(--text-main);
+ padding: 1rem 1.5rem;
+ border-radius: 8px;
+ border-left: 4px solid var(--danger);
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
+ z-index: 9999;
+ max-width: 90%;
+ width: auto;
+ transition: transform 0.3s ease;
+ font-size: 0.95rem;
+ line-height: 1.5;
+}
+
+.toast.show {
+ transform: translateX(-50%) translateY(0);
+}
+
+.toast-error {
+ border-left-color: var(--danger);
+ background: linear-gradient(135deg, rgba(255, 85, 85, 0.1), rgba(255, 85, 85, 0.05));
+}
+
+.toast-success {
+ border-left-color: var(--accent);
+ 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 {
+ border-left-color: var(--info);
+ background: linear-gradient(135deg, rgba(79, 195, 247, 0.1), rgba(79, 195, 247, 0.05));
+}
+
+.alert {
+ padding: 1.25rem 1.5rem;
+ margin-bottom: 1rem;
+ border-left: 4px solid;
+ border-radius: 4px;
+ font-weight: 500;
+ display: flex;
+ align-items: flex-start;
+ gap: 1rem;
+ animation: slideInDown 0.3s ease-out;
+ color: var(--text-bright);
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ margin: 0;
+ border-radius: 0;
+ z-index: 1000;
+ max-height: 100px;
+}
+
+.alert-error,
+.error {
+ background: linear-gradient(135deg, rgba(255, 85, 85, 0.15), rgba(255, 85, 85, 0.08));
+ border-left-color: #ff5555;
+ color: #ffb0b0;
+}
+
+.alert-success {
+ 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: var(--accent);
+ filter: brightness(1.2);
+}
+
+.alert-info {
+ background: linear-gradient(135deg, rgba(79, 195, 247, 0.15), rgba(79, 195, 247, 0.08));
+ border-left-color: var(--info);
+ color: #4fc3f7;
+}
+
+@keyframes slideInDown {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.status {
+ padding: 2rem 1rem;
+ text-align: center;
+}
+
+/* ============================================
+ Images & Media
+ ============================================ */
+
+img {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ border-radius: 6px;
+}
+
+pre {
+ overflow-x: auto;
+ background-color: #0a0a0a;
+ padding: 1rem;
+ border-radius: 6px;
+ border: 1px solid #333;
+ max-width: 100%;
+ word-break: break-all;
+ white-space: pre-wrap;
+ font-family: var(--font-pixel);
+ color: var(--accent);
+ font-size: 0.9rem;
+ line-height: 1.4;
+}
+
+code {
+ word-break: break-all;
+ max-width: 100%;
+ font-family: var(--font-pixel);
+ color: var(--accent);
+}
+
.ui-image {
- width: 100%;
+ width: 100%;
+ border-radius: 6px;
+ box-shadow: var(--shadow-md);
+ margin-bottom: 16px;
}
.pixelated {
- image-rendering: optimizeSpeed; /* Legal fallback */
- image-rendering: -moz-crisp-edges; /* Firefox */
- image-rendering: -o-crisp-edges; /* Opera */
- image-rendering: -webkit-optimize-contrast; /* Safari */
- image-rendering: optimize-contrast; /* CSS3 Proposed */
- image-rendering: crisp-edges; /* CSS4 Proposed */
- image-rendering: pixelated; /* CSS4 Proposed */
- -ms-interpolation-mode: nearest-neighbor; /* IE8+ */
+ image-rendering: optimizeSpeed;
+ image-rendering: -moz-crisp-edges;
+ image-rendering: -o-crisp-edges;
+ image-rendering: -webkit-optimize-contrast;
+ image-rendering: optimize-contrast;
+ image-rendering: crisp-edges;
+ image-rendering: pixelated;
+ -ms-interpolation-mode: nearest-neighbor;
+ margin-bottom: 1rem;
}
-.image-wrapper {
- flex: 1;
- position: relative;
+/* ============================================
+ Tooltips
+ ============================================ */
+
+.btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.5rem 1rem;
+ border-radius: 50px;
+ border: none;
+ font-weight: 400;
+ text-transform: uppercase;
+ font-size: 0.85rem;
+ min-height: 36px;
+ white-space: nowrap;
+ text-decoration: none;
+ transition: var(--transition);
+ cursor: pointer;
}
-div.status {
- position: absolute;
- top: 0;
+.btn.primary {
+ background-color: var(--accent);
+ color: #000;
+}
+
+.btn.primary:hover {
+ background-color: var(--accent);
+ filter: brightness(1.2);
+ text-decoration: none;
+ transform: translateY(-2px);
+}
+
+.btn.danger {
+ background-color: var(--danger);
+ color: #fff;
+}
+
+.btn.danger:hover {
+ background-color: var(--danger-hover);
+ text-decoration: none;
+ transform: translateY(-2px);
+}
+
+.read {
+ color: var(--text-secondary) !important;
+}
+
+/* ============================================
+ Utility Classes
+ ============================================ */
+
+.text-center {
+ text-align: center;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.text-left {
+ text-align: left;
+}
+
+.text-muted {
+ color: var(--text-secondary);
+}
+
+.mt-1 {
+ margin-top: 0.5rem;
+}
+
+.mt-2 {
+ margin-top: 1rem;
+}
+
+.mt-3 {
+ margin-top: 1.5rem;
+}
+
+.mb-1 {
+ margin-bottom: 0.5rem;
+}
+
+.mb-2 {
+ margin-bottom: 1rem;
+}
+
+.mb-3 {
+ margin-bottom: 1.5rem;
+}
+
+.p-1 {
+ padding: 0.5rem;
+}
+
+.p-2 {
+ padding: 1rem;
+}
+
+.p-3 {
+ padding: 1.5rem;
+}
+
+.hidden {
+ display: none !important;
+}
+
+/* ============================================
+ Responsive Design
+ ============================================ */
+
+@media (max-width: 1024px) {
+ .plugins-box {
+ flex: 1 1 calc(50% - 1rem);
+ }
+}
+
+@media (max-width: 768px) {
+ body {
+ font-size: 0.95rem;
+ }
+
+ h1 {
+ font-size: 1.5rem;
+ }
+
+ h2 {
+ font-size: 1.25rem;
+ }
+
+ .page-content {
+ padding: 16px;
+ }
+
+ .navbar-item a {
+ padding: 0.5rem;
+ font-size: 0.65rem;
+ min-height: 60px;
+ gap: 0.2rem;
+ }
+
+ .navbar-icon {
+ width: 1.3rem;
+ height: 1.3rem;
+ }
+
+ button,
+ input[type="submit"],
+ input[type="button"],
+ input[type="reset"],
+ .btn {
+ padding: 10px 20px;
+ font-size: 0.9rem;
+ min-width: 100px;
+ }
+
+ .switch {
+ width: 2.5rem;
+ height: 1.5rem;
+ }
+
+ .slider:before {
+ height: 1rem;
+ width: 1rem;
+ }
+
+ input:checked + .slider:before {
+ transform: translateX(1rem);
+ }
+
+ .plugins-box {
+ flex: 1 1 calc(50% - 0.75rem);
+ margin: 0.375rem;
+ padding: 0.75rem;
+ }
+
+ #container {
+ gap: 0;
+ }
+
+ input[type="text"],
+ input[type="email"],
+ input[type="password"],
+ input[type="number"],
+ textarea,
+ select {
+ font-size: 16px;
+ }
+}
+
+@media (max-width: 480px) {
+ body {
+ font-size: 0.9rem;
+ }
+
+ h1 {
+ font-size: 1.25rem;
+ }
+
+ h2 {
+ font-size: 1.1rem;
+ }
+
+ h3 {
+ font-size: 1rem;
+ }
+
+ h4 {
+ font-size: 1rem;
+ }
+
+ .page-header {
+ padding: 20px 15px;
+ margin-bottom: 15px;
+ }
+
+ .page-footer {
+ position: fixed;
+ bottom: 0;
left: 0;
+ right: 0;
+ margin: 0;
+ overflow: hidden;
+ }
+
+ .page-content {
+ padding: 12px 12px 80px 12px;
+ }
+
+ .toast {
+ bottom: 70px;
+ }
+
+ .navbar-item a {
+ padding: 0.4rem 0.25rem;
+ font-size: 0.6rem;
+ min-height: 50px;
+ gap: 0.1rem;
+ }
+
+ .navbar-icon {
+ width: 1.1rem;
+ height: 1.1rem;
+ }
+
+ button,
+ input[type="submit"],
+ input[type="button"],
+ input[type="reset"],
+ .btn {
+ padding: 8px 16px;
+ font-size: 0.85rem;
+ min-width: 80px;
width: 100%;
+ margin: 0.5rem 0;
+ }
+
+ .switch {
+ width: 2.5rem;
+ height: 1.4rem;
+ }
+
+ .slider:before {
+ height: 0.9rem;
+ width: 0.9rem;
+ bottom: 0.2rem;
+ }
+
+ input:checked + .slider:before {
+ transform: translateX(1rem);
+ }
+
+ .plugins-box {
+ flex: 1 1 100%;
+ min-height: auto;
+ margin: 0.5rem 0;
+ padding: 0.5rem;
+ }
+
+ .field-inline {
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ input[type="text"],
+ input[type="email"],
+ input[type="password"],
+ input[type="number"],
+ textarea,
+ select,
+ label {
+ font-size: 0.9rem;
+ }
+
+ .message-input-area {
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .message-input-area input {
+ width: 100%;
+ }
+
+ .message-input-area button {
+ width: 100%;
+ }
+
+ #container {
+ gap: 0;
+ }
+
+ .message-actions {
+ flex-direction: column;
+ }
+
+ .message-actions a {
+ width: 100%;
+ }
+
+ form {
+ gap: 0.75rem;
+ }
}
-a.read {
- color: #777 !important;
-}
+/* ============================================
+ Print Styles
+ ============================================ */
-p.messagebody {
- padding: 1em;
-}
+@media print {
+ body {
+ background-color: white;
+ color: black;
+ }
-li.navitem {
- width: 16.66% !important;
- clear: none !important;
-}
+ .page-footer {
+ display: none;
+ }
-/* Custom indentations are needed because the length of custom labels differs from
- the length of the standard labels */
-.custom-size-flipswitch.ui-flipswitch .ui-btn.ui-flipswitch-on {
- text-indent: -5.9em;
-}
-
-.custom-size-flipswitch.ui-flipswitch .ui-flipswitch-off {
- text-indent: 0.5em;
-}
-
-/* Custom widths are needed because the length of custom labels differs from
- the length of the standard labels */
-.custom-size-flipswitch.ui-flipswitch {
- width: 8.875em;
-}
-
-.custom-size-flipswitch.ui-flipswitch.ui-flipswitch-active {
- padding-left: 7em;
- width: 1.875em;
-}
-
-@media (min-width: 28em) {
- /*Repeated from rule .ui-flipswitch above*/
- .ui-field-contain > label + .custom-size-flipswitch.ui-flipswitch {
- width: 1.875em;
- }
-}
-
-#container {
- display: flex;
- flex-wrap: wrap;
- justify-content: space-around;
-}
-
-.plugins-box {
- margin: 0.5rem;
- padding: 0.2rem;
- border-style: groove;
- border-radius: 0.5rem;
- background-color: lightgrey;
- text-align: center;
+ a {
+ color: inherit;
+ text-decoration: underline;
+ }
}
diff --git a/pwnagotchi/ui/web/templates/base.html b/pwnagotchi/ui/web/templates/base.html
index 921dbaa8..6f92b210 100644
--- a/pwnagotchi/ui/web/templates/base.html
+++ b/pwnagotchi/ui/web/templates/base.html
@@ -1,86 +1,73 @@
-
-
-{% block head %}
-
+
+
+ {% block head %}
+
{% block meta %}
-
-
-
+
+
+
+
{% endblock %}
-
- {% block title %}
- {% endblock %}
-
+ {% block title %}{% endblock %}
{% block styles %}
-
-
-
-
- {% endblock %}
-
-
-{% endblock %}
-
-{% block body %}
-
-
-
- {% if error %}
-
-
+
+
+ {% if active_page == 'profile' %}
+
+ {% elif active_page in ['inbox', 'new'] %}
+
+ {% elif active_page == 'plugins' %}
+
{% endif %}
+
+
+ {% endblock %}
+
- {% set navigation = [
- ( '/', 'home', 'eye', 'Home' ),
- ( '/inbox', 'inbox', 'bars', 'Inbox' ),
- ( '/inbox/new', 'new', 'mail', 'New' ),
- ( '/inbox/profile', 'profile', 'info', 'Profile' ),
- ( '/inbox/peers', 'peers', 'user', 'Peers' ),
- ( '/plugins', 'plugins', 'grid', 'Plugins' ),
- ] %}
- {% set active_page = active_page|default('inbox') %}
+ {% endblock %} {% block body %}
+
+
+ {% if error %}
+
+ {% endif %} {% set navigation = [ ( '/', 'home', 'Home' ), ( '/inbox',
+ 'inbox', 'Inbox' ), ( '/inbox/new', 'new', 'New' ), ( '/inbox/profile',
+ 'profile', 'Profile' ), ( '/inbox/peers', 'peers', 'Peers' ), (
+ '/plugins', 'plugins', 'Plugins' ), ] %} {% set active_page =
+ active_page|default('inbox') %}
-
-
-
- {% for href, id, icon, caption in navigation %}
- -
- {{ caption }}
-
- {% endfor %}
-
-
+
{% block content %} {% endblock %}
+
+
- {% block content %}
+ {% block scripts %}
+
+
+
{% endblock %}
-
-
-{% block scripts %}
-
-
-
-
-
-{% endblock %}
-
-{% endblock %}
+
+ {% endblock %}