diff --git a/docker-compose.yml b/docker-compose.yml index e19f30d..807488d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,22 @@ +version: '3.8' + services: offlineu: - build: . - pull_policy: build - container_name: offlineu - network_mode: host + image: ghcr.io/skippysteve/offlineu:main + container_name: offlineu-app ports: - "5000:5000" environment: - - PUID=1000 - - PGID=10 - - TZ=America/New_York - FLASK_ENV=production volumes: # Mount a local directory for course data persistence - - /volume1/files/training:/app/courses + - ./courses:/app/courses # Mount a local directory for user data/progress - - /volume2/docker/offlineu/data:/app/data - restart: unless-stopped \ No newline at end of file + - ./data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s \ No newline at end of file diff --git a/offlineu_core.py b/offlineu_core.py index c4c18eb..2f01b37 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -32,6 +32,91 @@ QUIZ_INDICATORS = {'quiz', 'exam', 'test', 'assessment', 'exercise', 'assignment # COURSES_LIBRARY_PATH env var. LIBRARY_PATH = os.environ.get('COURSES_LIBRARY_PATH', '/app/courses') +# App-wide (non per-course) persisted data, e.g. display settings. Matches +# the ./data volume mount in docker-compose.yml. +DATA_DIR = os.environ.get('OFFLINEU_DATA_DIR', '/app/data') +SETTINGS_FILE = os.path.join(DATA_DIR, 'settings.json') + +DEFAULT_SETTINGS = { + 'theme': 'dark', # 'dark' | 'light' + 'accent_color': '#007acc', # any #rrggbb hex + 'font_family': 'system', # 'system' | 'sans' | 'serif' | 'monospace' + 'font_size': 'medium', # 'small' | 'medium' | 'large' | 'xlarge' + 'layout_width': 'wide', # 'normal' | 'wide' | 'full' + 'density': 'comfortable', # 'comfortable' | 'compact' + 'card_style': 'flat', # 'flat' | 'elevated' | 'bordered' + 'corner_radius': 'rounded', # 'sharp' | 'rounded' | 'pill' +} + +# Allowed values for every setting except accent_color (validated separately +# as a hex string). Anything outside these sets is rejected/ignored on save. +SETTINGS_CHOICES = { + 'theme': {'dark', 'light'}, + 'font_family': {'system', 'sans', 'serif', 'monospace'}, + 'font_size': {'small', 'medium', 'large', 'xlarge'}, + 'layout_width': {'normal', 'wide', 'full'}, + 'density': {'comfortable', 'compact'}, + 'card_style': {'flat', 'elevated', 'bordered'}, + 'corner_radius': {'sharp', 'rounded', 'pill'}, +} + +# How each choice resolves to an actual CSS value. Keeping this server-side +# (rather than duplicating the mapping in JS) means the client just applies +# whatever /api/settings hands back. +FONT_FAMILY_CSS = { + 'system': "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif", + 'sans': "Arial, Helvetica, sans-serif", + 'serif': "Georgia, 'Times New Roman', serif", + 'monospace': "'Courier New', Consolas, monospace", +} +FONT_SIZE_CSS = {'small': '14px', 'medium': '16px', 'large': '18px', 'xlarge': '20px'} +LAYOUT_WIDTH_CSS = {'normal': '1000px', 'wide': '1600px', 'full': '100%'} +RADIUS_CSS = {'sharp': '2px', 'rounded': '8px', 'pill': '999px'} + +HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$') + + +def load_settings() -> Dict[str, Any]: + """Load persisted display settings, filling in defaults for anything missing/invalid.""" + settings = dict(DEFAULT_SETTINGS) + try: + if os.path.exists(SETTINGS_FILE): + with open(SETTINGS_FILE, 'r') as f: + saved = json.load(f) + for key, value in saved.items(): + if key == 'accent_color' and isinstance(value, str) and HEX_COLOR_RE.match(value): + settings[key] = value + elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]: + settings[key] = value + except (json.JSONDecodeError, OSError) as e: + print(f"Could not load settings, using defaults: {e}") + return settings + + +def save_settings(new_settings: Dict[str, Any]) -> Dict[str, Any]: + """Validate and persist display settings; unknown/invalid keys are ignored.""" + current = load_settings() + for key, value in new_settings.items(): + if key == 'accent_color' and isinstance(value, str) and HEX_COLOR_RE.match(value): + current[key] = value + elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]: + current[key] = value + os.makedirs(DATA_DIR, exist_ok=True) + with open(SETTINGS_FILE, 'w') as f: + json.dump(current, f, indent=2) + return current + + +def settings_css_vars(settings: Dict[str, Any]) -> Dict[str, str]: + """Resolve a settings dict into the actual CSS custom-property values.""" + return { + '--accent': settings['accent_color'], + '--font-family': FONT_FAMILY_CSS[settings['font_family']], + '--font-size-base': FONT_SIZE_CSS[settings['font_size']], + '--container-max-width': LAYOUT_WIDTH_CSS[settings['layout_width']], + '--radius': RADIUS_CSS[settings['corner_radius']], + } + @dataclass class Lesson: @@ -545,6 +630,61 @@ def browse_library(): }) +@app.route('/settings') +def settings_page(): + """Render the display-settings page.""" + current = load_settings() + return render_template( + 'settings.html', + settings=current, + theme_choices=sorted(SETTINGS_CHOICES['theme']), + font_family_choices=['system', 'sans', 'serif', 'monospace'], + font_size_choices=['small', 'medium', 'large', 'xlarge'], + layout_width_choices=['normal', 'wide', 'full'], + density_choices=['comfortable', 'compact'], + card_style_choices=['flat', 'elevated', 'bordered'], + corner_radius_choices=['sharp', 'rounded', 'pill'], + ) + + +@app.route('/api/settings', methods=['GET']) +def get_settings_api(): + """Return current settings plus their resolved CSS values, for the shared theme script.""" + current = load_settings() + return jsonify({ + 'settings': current, + 'css_vars': settings_css_vars(current) + }) + + +@app.route('/api/settings', methods=['POST']) +def save_settings_api(): + """Persist updated display settings.""" + try: + new_settings = request.get_json(force=True) or {} + saved = save_settings(new_settings) + return jsonify({ + 'success': True, + 'settings': saved, + 'css_vars': settings_css_vars(saved) + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 400 + + +@app.route('/api/settings/reset', methods=['POST']) +def reset_settings_api(): + """Reset settings back to defaults.""" + os.makedirs(DATA_DIR, exist_ok=True) + with open(SETTINGS_FILE, 'w') as f: + json.dump(DEFAULT_SETTINGS, f, indent=2) + return jsonify({ + 'success': True, + 'settings': DEFAULT_SETTINGS, + 'css_vars': settings_css_vars(DEFAULT_SETTINGS) + }) + + @app.route('/load_course', methods=['POST']) def load_course(): """Load course from selected directory""" diff --git a/static/theme.js b/static/theme.js new file mode 100644 index 0000000..4ea65f9 --- /dev/null +++ b/static/theme.js @@ -0,0 +1,30 @@ +// Applies the user's saved display settings (theme, accent color, font, +// layout width, density, card style, corner radius) to whichever page +// includes this script. Settings are fetched from /api/settings, which +// resolves the stored choices into actual CSS values server-side, so +// this file doesn't need to duplicate any of those mappings. +(function () { + function apply(data) { + const root = document.documentElement; + const vars = data.css_vars || {}; + Object.keys(vars).forEach(function (name) { + root.style.setProperty(name, vars[name]); + }); + + const settings = data.settings || {}; + if (settings.theme) root.setAttribute('data-theme', settings.theme); + if (settings.density) root.setAttribute('data-density', settings.density); + if (settings.card_style) root.setAttribute('data-card-style', settings.card_style); + } + + fetch('/api/settings') + .then(function (r) { return r.json(); }) + .then(apply) + .catch(function (err) { + console.warn('Could not load display settings, using page defaults:', err); + }); + + // Exposed so the settings page itself can live-preview changes + // before saving. + window.__offlineuApplyTheme = apply; +})(); diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index 8f58d9a..e2e72da 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -11,35 +11,78 @@ box-sizing: border-box; } + :root { + --bg-primary: #1a1a1a; + --bg-secondary: #2d2d2d; + --bg-tertiary: #3d3d3d; + --bg-tertiary-hover: #404040; + --text-primary: #e0e0e0; + --text-muted: #999; + --border-color: #555; + --accent: #007acc; + --accent-hover: #005a9e; + --font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + --font-size-base: 16px; + --container-max-width: 1600px; + --radius: 8px; + } + [data-theme="light"] { + --bg-primary: #f2f2f2; + --bg-secondary: #ffffff; + --bg-tertiary: #eeeeee; + --bg-tertiary-hover: #e2e2e2; + --text-primary: #222222; + --text-muted: #666666; + --border-color: #cccccc; + } + [data-density="compact"] .container { padding: 10px; } + [data-density="compact"] .card, + [data-density="compact"] .lesson-item, + [data-density="compact"] .tree-header, + [data-density="compact"] .course-selector { padding: 10px; margin-bottom: 8px; } + [data-card-style="elevated"] .card, + [data-card-style="elevated"] .lesson-item, + [data-card-style="elevated"] .tree-header, + [data-card-style="elevated"] .course-selector { + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); + } + [data-card-style="bordered"] .card, + [data-card-style="bordered"] .lesson-item, + [data-card-style="bordered"] .tree-header, + [data-card-style="bordered"] .course-selector { + border: 1px solid var(--border-color); + } + body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background: #1a1a1a; - color: #e0e0e0; + font-family: var(--font-family); + font-size: var(--font-size-base); + background: var(--bg-primary); + color: var(--text-primary); line-height: 1.6; } .container { - max-width: 1600px; + max-width: var(--container-max-width); width: 95%; margin: 0 auto; padding: 20px; } .header { - background: #2d2d2d; + background: var(--bg-secondary); padding: 20px 0; margin-bottom: 30px; - border-bottom: 3px solid #007acc; + border-bottom: 3px solid var(--accent); } .header h1 { - color: #007acc; + color: var(--accent); text-align: center; font-size: 2.5em; } .nav { - background: #333; + background: var(--bg-tertiary); padding: 10px 0; margin-bottom: 20px; } @@ -51,31 +94,31 @@ } .nav a { - color: #e0e0e0; + color: var(--text-primary); text-decoration: none; padding: 10px 15px; - border-radius: 5px; + border-radius: var(--radius); transition: background 0.3s; } .nav a:hover { - background: #007acc; + background: var(--accent); } .card { - background: #2d2d2d; - border-radius: 8px; + background: var(--bg-secondary); + border-radius: var(--radius); padding: 20px; margin-bottom: 20px; - border-left: 4px solid #007acc; + border-left: 4px solid var(--accent); } .btn { - background: #007acc; + background: var(--accent); color: white; border: none; padding: 12px 24px; - border-radius: 5px; + border-radius: var(--radius); cursor: pointer; text-decoration: none; display: inline-block; @@ -84,7 +127,7 @@ } .btn:hover { - background: #005a9e; + background: var(--accent-hover); } .btn:disabled { @@ -109,7 +152,7 @@ } .progress-fill { - background: linear-gradient(90deg, #007acc, #00a0ff); + background: var(--accent); height: 100%; transition: width 0.3s ease; border-radius: 10px; @@ -125,9 +168,9 @@ } .tree-header { - background: #3d3d3d; + background: var(--bg-tertiary); padding: 12px 15px; - border-radius: 5px; + border-radius: var(--radius); cursor: pointer; display: flex; align-items: center; @@ -137,11 +180,11 @@ } .tree-header:hover { - background: #404040; + background: var(--bg-tertiary-hover); } .tree-header.directory { - border-left-color: #007acc; + border-left-color: var(--accent); } .tree-header.lesson { @@ -176,14 +219,14 @@ .tree-stats { font-size: 0.85em; - color: #999; + color: var(--text-muted); margin-left: 10px; } .tree-toggle { background: none; border: none; - color: #e0e0e0; + color: var(--text-primary); font-size: 1.2em; cursor: pointer; padding: 5px; @@ -206,9 +249,9 @@ } .lesson-item { - background: #3d3d3d; + background: var(--bg-tertiary); padding: 10px 15px; - border-radius: 5px; + border-radius: var(--radius); margin-bottom: 5px; display: flex; justify-content: space-between; @@ -219,8 +262,8 @@ } .lesson-item:hover { - border-left-color: #007acc; - background: #404040; + border-left-color: var(--accent); + background: var(--bg-tertiary-hover); transform: translateX(5px); } @@ -246,7 +289,7 @@ .lesson-meta { font-size: 0.85em; - color: #999; + color: var(--text-muted); display: flex; align-items: center; gap: 5px; @@ -285,27 +328,27 @@ text-align: center; margin: 40px 0; border-radius: 10px; - background: #2d2d2d; + background: var(--bg-secondary); } .course-selector input { - background: #3d3d3d; + background: var(--bg-tertiary); border: 1px solid #666; - color: #e0e0e0; + color: var(--text-primary); padding: 12px; font-size: 14px; - border-radius: 5px; + border-radius: var(--radius); width: 80%; margin-right: 10px; } .course-selector input::placeholder { - color: #999; + color: var(--text-muted); } .course-selector input:focus { outline: none; - border-color: #007acc; + border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2); } @@ -356,6 +399,7 @@
← Select Different Course Progress + ⚙ Settings {{ stats.total_lessons }} lessons @@ -444,7 +488,10 @@ {% else %}
-

Your Courses

+
+

Your Courses

+ ⚙ Settings +

@@ -629,5 +676,6 @@ } }); + \ No newline at end of file diff --git a/templates/lesson_view.html b/templates/lesson_view.html index 1f46174..ae699a6 100644 --- a/templates/lesson_view.html +++ b/templates/lesson_view.html @@ -3,24 +3,63 @@ {{ lesson.title }} - {{ course.name }} + + +
+ ← Back +

Settings

+

Changes save and apply immediately across the app.

+ +
+

Appearance

+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ +
+

Typography

+
+ + +
+
+ + +
+
+ +
+

Layout

+
+ + +
+
+ + +
+
+ +
+ + ✓ Saved +
+
+ + + + +