micro-sidebar 1.2.2__py3-none-any.whl → 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: micro-sidebar
3
- Version: 1.2.2
3
+ Version: 2.1.0
4
4
  Summary: A Reusable RTL Django Sidebar App
5
5
  Home-page: https://github.com/debeski/micro-sidebar
6
6
  Author: DeBeski
@@ -66,6 +66,119 @@ Requires-Dist: Django (>=5.1)
66
66
  ]
67
67
  ```
68
68
 
69
+ ## Auto-Discovery Mode (New in v2.0.0)
70
+
71
+ The sidebar can **automatically discover** your list views and generate navigation items!
72
+
73
+ ### Setup
74
+
75
+ 1. **Add context processor** to `settings.py`:
76
+ ```python
77
+ TEMPLATES = [{
78
+ 'OPTIONS': {
79
+ 'context_processors': [
80
+ ...
81
+ 'sidebar.context_processors.sidebar_context',
82
+ ],
83
+ },
84
+ }]
85
+ ```
86
+
87
+ 2. **Use in your sidebar template:**
88
+ ```html
89
+ {% extends "sidebar/main.html" %}
90
+ {% load sidebar_tags %}
91
+
92
+ {% block items %}
93
+ {% auto_sidebar %}
94
+ {% endblock %}
95
+ ```
96
+
97
+ That's it! The sidebar will automatically find all URLs with `list` in their name (e.g., `decree_list`), match them to models, and display `verbose_name_plural` as labels.
98
+
99
+ ### Configuration (Optional)
100
+
101
+ Add to `settings.py`:
102
+ ```python
103
+ SIDEBAR_AUTO = {
104
+ 'ENABLED': True, # Enable auto-discovery
105
+ 'URL_PATTERNS': ['list'], # Keywords to match in URL names
106
+ 'EXCLUDE_APPS': ['admin', 'auth'], # Apps to exclude
107
+ 'EXCLUDE_MODELS': [], # Specific models to exclude
108
+ 'CACHE_TIMEOUT': 3600, # Cache timeout in seconds
109
+ 'DEFAULT_ICON': 'bi-list', # Default Bootstrap icon
110
+ 'DEFAULT_ITEMS': {}, # See "Default Items" section below
111
+ 'EXTRA_ITEMS': {}, # See "Extra Items" section below
112
+ }
113
+ ```
114
+
115
+ ### Default Items Configuration
116
+
117
+ Use `DEFAULT_ITEMS` to customize auto-discovered items (labels, icons, ordering) without modifying your models:
118
+
119
+ ```python
120
+ SIDEBAR_AUTO = {
121
+ # ...
122
+ 'DEFAULT_ITEMS': {
123
+ 'decree_list': { # Key is the URL name
124
+ 'label': 'Decisions', # Override display label
125
+ 'icon': 'bi-gavel', # Override icon
126
+ 'order': 10, # Sort order (lower = first)
127
+ },
128
+ 'incoming_list': {
129
+ 'order': 20, # Only setting order
130
+ }
131
+ }
132
+ }
133
+ ```
134
+
135
+ Items not in `DEFAULT_ITEMS` will use defaults:
136
+ - **Label**: Model's `verbose_name_plural`
137
+ - **Icon**: `DEFAULT_ICON`
138
+ - **Order**: 100
139
+
140
+ ### Extra Items (Non-Model URLs)
141
+
142
+ For URLs that don't map to a model (e.g., management pages), use `EXTRA_ITEMS`:
143
+
144
+ ```python
145
+ SIDEBAR_AUTO = {
146
+ # ... other config ...
147
+ 'EXTRA_ITEMS': {
148
+ 'الإدارة': { # Group name (accordion header)
149
+ 'icon': 'bi-gear',
150
+ 'items': [
151
+ {
152
+ 'url_name': 'manage_sections',
153
+ 'label': 'إدارة الأقسام',
154
+ 'icon': 'bi-diagram-3',
155
+ 'permission': 'documents.manage_sections',
156
+ },
157
+ {
158
+ 'url_name': 'manage_users',
159
+ 'label': 'إدارة المستخدمين',
160
+ 'icon': 'bi-people',
161
+ 'permission': 'is_staff', # or a special check to Only show to staff users.
162
+ },
163
+ ]
164
+ }
165
+ }
166
+ }
167
+ ```
168
+
169
+ Then in your sidebar template:
170
+ ```html
171
+ {% extends "sidebar/main.html" %}
172
+ {% load sidebar_tags %}
173
+
174
+ {% block items %}
175
+ {% auto_sidebar %}
176
+ {% extra_sidebar %}
177
+ {% endblock %}
178
+ ```
179
+
180
+ Extra items appear at the bottom of the sidebar, grouped in Bootstrap accordions.
181
+
69
182
  ## Customization
70
183
 
71
184
  ### Override Default Menu
@@ -115,3 +228,5 @@ While it may theoretically work in LTR environments if standard Bootstrap files
115
228
  | **v1.2.0** | **New Theme Implementation:** Redesigned UI with rounded pill-shaped items, tactile micro-animations, and a refined color palette. Improved responsiveness with dynamic top-offset calculations and inline FOUC fixes for small screens. Fixed tooltip stickiness bug. |
116
229
  | **v1.2.1** | **Positioning Fix:** Added `align-self: flex-start` to resolve 60px vertical offset in flex containers. Removed legacy `sidebar-top-offset` CSS variable and JS calculations. Added `box-shadow: none` and `outline: none` to accordion buttons to remove focus ring. Fixed page flickering on wider screens by constraining sidebar height with `calc(100vh - header-height)`. |
117
230
  | **v1.2.2** | **CSP Compliance:** Added `nonce` attribute support to inline scripts for Content Security Policy compliance. |
231
+ | **v2.0.0** | **Auto-Discovery:** New feature that introspects Django URL patterns and models to automatically generate sidebar navigation items. Adds `{% auto_sidebar %}` template tag, context processor, and configuration options. |
232
+ | **v2.1.0** | **Refactor & Enhancements:** Decoupled customization from models by introducing `DEFAULT_ITEMS` setting for overriding auto-discovered items' labels/icons/order. Added `EXTRA_ITEMS` setting for manual, permission-aware sidebar links grouped in accordions with `{% extra_sidebar %}` tag. Removed deprecated model-level `sidebar_*` attributes. |
@@ -0,0 +1,29 @@
1
+ sidebar/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ sidebar/apps.py,sha256=iF89pf0Vfs32KLibizTjb9crYPOb3sKcpS0MddBYGrM,381
3
+ sidebar/context_processors.py,sha256=g4TmjkAdyJDqGmQ7c1EhRtDUyt7gHQTPsNzbzy0o_sY,4221
4
+ sidebar/discovery.py,sha256=6rBpji-g057Pwg3fS7Vp1JV61wz3ygmhZfosGMNXjAY,5636
5
+ sidebar/urls.py,sha256=UL_9e1RLNMxZXkah65m7GRU1dbViZRGeNPBIiSZpOYg,142
6
+ sidebar/views.py,sha256=MebyJ1ZiylSOPESXFkkQ8QTg-ClrkJn-oYLN6KrcgiM,418
7
+ sidebar/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ sidebar/static/sidebar/sidebar.css,sha256=2mO5IlDZawzFds5KE9NCpM1UwB-e5HHlnRj2gfK28vs,5995
9
+ sidebar/static/sidebar/sidebar.js,sha256=gcurW6nl0Dt9jPTzk749DX7uQ3U_7De4F6Gj8ci6W40,5233
10
+ sidebar/static/sidebar/css/theme_picker.css,sha256=S2u9p_4rXZALf7hoCOsgyAU91NP3Hoy54WYnc4aGyww,4345
11
+ sidebar/static/sidebar/js/theme_picker.js,sha256=Kt4S2fd0ocFjqeuOQXDcvWVLrLDAVxs7zANZqzIqA4g,2292
12
+ sidebar/static/themes/blue.css,sha256=_CMBLoX8xuSkEdiSYP4HhM-_M368q5Zus9n2kGILBQw,1651
13
+ sidebar/static/themes/dark.css,sha256=JmLG6UWSB7Erwogz0tCcpZUL1hgwxqgfyx-WSCXBtLU,14854
14
+ sidebar/static/themes/gold.css,sha256=2Ry4ba5CIRvgYFTiAaeh5K5DUjvbX1uyVIcolVTPiwE,1619
15
+ sidebar/static/themes/green.css,sha256=y-P-ShJ6ISOqT6FnuS2PNKNQX0kr4Whx-uMLozhPkvY,1951
16
+ sidebar/static/themes/light.css,sha256=XqStIa8wTzWswWyy2nqbk4tMPKUuIR4967PBoJoPuOc,1717
17
+ sidebar/static/themes/main.css,sha256=NaPBDAPL-PkTphtcIto7Qjb5CkiR9T_X5YztDT5SwwE,121
18
+ sidebar/static/themes/main.js,sha256=DSz07M8c1KTXSlj7woxPq0Kf9VFb2uNCI13Cz87gghs,1412
19
+ sidebar/static/themes/red.css,sha256=t8OTmvgszQXX4YtYxlIM86zjx1jp-SvfVx2G3X0FjDI,1613
20
+ sidebar/templates/sidebar/auto.html,sha256=nPKOcWMQ9aYNnH6z2EswsBE76zuxL4mUYUv8JyvsyWA,476
21
+ sidebar/templates/sidebar/extra_groups.html,sha256=uMjaXhCuDitIattfChKdvyl6KQj6OVuMG4-bkg8Lb9E,1525
22
+ sidebar/templates/sidebar/main.html,sha256=5-cPxjVAJ_lLvjI6IMwM3Lrov7gBZQAb4fh_vVTM160,3521
23
+ sidebar/templatetags/__init__.py,sha256=RC19QrlHdcgslFc_19Se9UhOe-7_WH9GtNGYU_cZlXg,48
24
+ sidebar/templatetags/sidebar_tags.py,sha256=KoZzjqRpMtiGcITKFUQBcs5RdbvtuzCaNrCWEzIbBlk,2245
25
+ micro_sidebar-2.1.0.dist-info/LICENSE,sha256=Fco89ULLSSxKkC2KKnx57SaT0R7WOkZfuk8IYcGiN50,1063
26
+ micro_sidebar-2.1.0.dist-info/METADATA,sha256=Qu2mhQ1Log9XThokGmFAtUc4haOkaIQeMU7n7MPFh3c,8264
27
+ micro_sidebar-2.1.0.dist-info/WHEEL,sha256=pkctZYzUS4AYVn6dJ-7367OJZivF2e8RA9b_ZBjif18,92
28
+ micro_sidebar-2.1.0.dist-info/top_level.txt,sha256=ih69sjMhU1wOB9HzUV90yEY98aiPuGhzPBBBE-YtJ3w,8
29
+ micro_sidebar-2.1.0.dist-info/RECORD,,
sidebar/apps.py CHANGED
@@ -1,5 +1,15 @@
1
1
  from django.apps import AppConfig
2
2
 
3
+
3
4
  class SidebarConfig(AppConfig):
4
5
  default_auto_field = 'django.db.models.BigAutoField'
5
6
  name = 'sidebar'
7
+
8
+ def ready(self):
9
+ """
10
+ Called when Django starts.
11
+ Import discovery module to ensure it's available.
12
+ """
13
+ # Import to make discovery available
14
+ from . import discovery # noqa: F401
15
+
@@ -0,0 +1,127 @@
1
+ """
2
+ Context processor for auto-discovered sidebar items.
3
+
4
+ Injects sidebar navigation items into every request context,
5
+ filtered by user permissions.
6
+ """
7
+ import hashlib
8
+ import json
9
+ from django.core.cache import cache
10
+ from django.urls import reverse, NoReverseMatch
11
+ from .discovery import discover_list_urls, get_sidebar_config
12
+
13
+
14
+ def _get_config_hash(config):
15
+ """Generate a hash of the config for cache key."""
16
+ # Exclude EXTRA_ITEMS from hash since they're processed separately
17
+ config_copy = {k: v for k, v in config.items() if k != 'EXTRA_ITEMS'}
18
+ config_str = json.dumps(config_copy, sort_keys=True)
19
+ return hashlib.md5(config_str.encode()).hexdigest()[:8]
20
+
21
+
22
+ def _process_extra_items(config, request):
23
+ """
24
+ Process EXTRA_ITEMS config into sidebar-ready format.
25
+
26
+ Returns dict of groups, each with icon and list of items with resolved URLs.
27
+ """
28
+ extra_items = config.get('EXTRA_ITEMS', {})
29
+ processed_groups = {}
30
+
31
+ for group_name, group_config in extra_items.items():
32
+ group_icon = group_config.get('icon', 'bi-gear')
33
+ items = []
34
+
35
+ for item in group_config.get('items', []):
36
+ url_name = item.get('url_name', '')
37
+
38
+ # Check permission if specified
39
+ permission = item.get('permission')
40
+ if permission:
41
+ if permission == 'is_staff' and not request.user.is_staff:
42
+ continue
43
+ elif permission == 'is_superuser' and not request.user.is_superuser:
44
+ continue
45
+ elif permission not in ['is_staff', 'is_superuser'] and not request.user.has_perm(permission):
46
+ continue
47
+
48
+ # Resolve URL
49
+ try:
50
+ url = reverse(url_name)
51
+ active = request.path == url or request.path.startswith(url.rstrip('/') + '/')
52
+ except NoReverseMatch:
53
+ url = '#'
54
+ active = False
55
+
56
+ items.append({
57
+ 'url_name': url_name,
58
+ 'url': url,
59
+ 'label': item.get('label', url_name),
60
+ 'icon': item.get('icon', 'bi-link'),
61
+ 'active': active,
62
+ })
63
+
64
+ if items: # Only add group if it has visible items
65
+ processed_groups[group_name] = {
66
+ 'icon': group_icon,
67
+ 'items': items,
68
+ 'has_active': any(item['active'] for item in items),
69
+ }
70
+
71
+ return processed_groups
72
+
73
+
74
+ def sidebar_context(request):
75
+ """
76
+ Add auto-discovered sidebar items to template context.
77
+
78
+ Items are cached for performance and filtered by user permissions.
79
+ Only authenticated users see sidebar items, and only items they
80
+ have view permission for.
81
+
82
+ Returns:
83
+ Dictionary with 'sidebar_auto_items' and 'sidebar_extra_groups' keys.
84
+ """
85
+ config = get_sidebar_config()
86
+ # Include config hash in cache key so settings changes invalidate cache
87
+ cache_key = f'sidebar_auto_items_{_get_config_hash(config)}'
88
+ items = cache.get(cache_key)
89
+
90
+ if items is None:
91
+ items = discover_list_urls()
92
+ cache.set(cache_key, items, timeout=config['CACHE_TIMEOUT'])
93
+
94
+ # Filter by user permissions
95
+ if request.user.is_authenticated:
96
+ if request.user.is_superuser:
97
+ # Superusers see everything
98
+ visible = items
99
+ else:
100
+ visible = []
101
+ for item in items:
102
+ if not item.get('permissions'):
103
+ visible.append(item)
104
+ elif any(request.user.has_perm(p) for p in item['permissions']):
105
+ visible.append(item)
106
+ items = visible
107
+
108
+ # Process extra items for authenticated users
109
+ extra_groups = _process_extra_items(config, request)
110
+ else:
111
+ items = []
112
+ extra_groups = {}
113
+
114
+ return {
115
+ 'sidebar_auto_items': items,
116
+ 'sidebar_extra_groups': extra_groups,
117
+ }
118
+
119
+
120
+ def clear_sidebar_cache():
121
+ """
122
+ Clear the sidebar items cache.
123
+
124
+ Call this when models or URLs change and sidebar needs refresh.
125
+ """
126
+ cache.delete('sidebar_auto_items')
127
+
sidebar/discovery.py ADDED
@@ -0,0 +1,151 @@
1
+ """
2
+ Auto-discovery module for sidebar navigation items.
3
+
4
+ Scans Django URL patterns for list views and matches them to models
5
+ to automatically generate sidebar navigation items.
6
+ """
7
+ from django.urls import get_resolver, URLPattern, URLResolver
8
+ from django.apps import apps
9
+ from django.conf import settings
10
+ from difflib import get_close_matches
11
+
12
+
13
+ def get_sidebar_config():
14
+ """Get sidebar configuration from Django settings with defaults."""
15
+ defaults = {
16
+ 'ENABLED': True,
17
+ 'URL_PATTERNS': ['list'],
18
+ 'EXCLUDE_APPS': ['admin', 'auth', 'contenttypes', 'sessions'],
19
+ 'EXCLUDE_MODELS': [],
20
+ 'CACHE_TIMEOUT': 3600,
21
+ 'DEFAULT_ICON': 'bi-list',
22
+ # Extra items that don't require model matching
23
+ # Format: {'group_name': {'icon': 'bi-gear', 'items': [{'url_name': 'x', 'label': 'X', 'icon': 'bi-x'}]}}
24
+ 'EXTRA_ITEMS': {},
25
+ # Default overrides for auto-discovered items
26
+ # Format: {'url_name': {'label': 'X', 'icon': 'bi-x', 'order': 10}}
27
+ 'DEFAULT_ITEMS': {},
28
+ }
29
+ user_config = getattr(settings, 'SIDEBAR_AUTO', {})
30
+ return {**defaults, **user_config}
31
+
32
+
33
+
34
+ def discover_list_urls():
35
+ """
36
+ Scan all URL patterns for names containing configured keywords.
37
+ Match them to Django models and extract verbose_name_plural.
38
+
39
+ Returns:
40
+ List of sidebar item dictionaries sorted by order.
41
+ """
42
+ config = get_sidebar_config()
43
+
44
+ if not config['ENABLED']:
45
+ return []
46
+
47
+ resolver = get_resolver()
48
+ items = []
49
+
50
+ for pattern in _iterate_patterns(resolver.url_patterns):
51
+ url_name = getattr(pattern, 'name', '') or ''
52
+
53
+ # Check if URL name contains any of the configured patterns
54
+ matched_keyword = None
55
+ for kw in config['URL_PATTERNS']:
56
+ if kw in url_name.lower():
57
+ matched_keyword = kw
58
+ break
59
+
60
+ if matched_keyword:
61
+ # Extract model hint (e.g., 'decree' from 'decree_list')
62
+ model_hint = url_name
63
+ for kw in config['URL_PATTERNS']:
64
+ model_hint = model_hint.replace(f'_{kw}', '').replace(f'-{kw}', '').replace(kw, '')
65
+
66
+ model_hint = model_hint.strip('_-')
67
+
68
+ # Try to find model - first with stripped hint, then with keyword itself
69
+ model = None
70
+ if model_hint:
71
+ model = _find_model(model_hint, config)
72
+
73
+ # If no model found and keyword looks like it could be a model name, try that
74
+ if not model and matched_keyword not in ['list', 'index', 'view', 'page']:
75
+ model = _find_model(matched_keyword, config)
76
+
77
+ if model:
78
+ # Check exclusions
79
+ if model._meta.app_label in config['EXCLUDE_APPS']:
80
+ continue
81
+ if f"{model._meta.app_label}.{model.__name__}" in config['EXCLUDE_MODELS']:
82
+ continue
83
+
84
+ items.append({
85
+ 'url_name': url_name,
86
+ 'label': getattr(model._meta, 'sidebar_label', None) or str(model._meta.verbose_name_plural),
87
+ 'icon': getattr(model._meta, 'sidebar_icon', config['DEFAULT_ICON']),
88
+ 'order': getattr(model._meta, 'sidebar_order', 100),
89
+ 'app_label': model._meta.app_label,
90
+ 'model_name': model._meta.model_name,
91
+ 'permissions': [f'{model._meta.app_label}.view_{model._meta.model_name}'],
92
+ })
93
+
94
+ # Override with DEFAULT_ITEMS if present
95
+ default_items = config.get('DEFAULT_ITEMS', {})
96
+ if url_name in default_items:
97
+ item_config = default_items[url_name]
98
+ # Update last added item
99
+ if items:
100
+ item = items[-1]
101
+ if 'label' in item_config:
102
+ item['label'] = item_config['label']
103
+ if 'icon' in item_config:
104
+ item['icon'] = item_config['icon']
105
+ if 'order' in item_config:
106
+ item['order'] = item_config['order']
107
+
108
+ return sorted(items, key=lambda x: (x['order'], x['label']))
109
+
110
+
111
+ def _iterate_patterns(patterns, prefix=''):
112
+ """Recursively iterate through URL patterns."""
113
+ for pattern in patterns:
114
+ if isinstance(pattern, URLResolver):
115
+ yield from _iterate_patterns(pattern.url_patterns, prefix + str(pattern.pattern))
116
+ elif isinstance(pattern, URLPattern):
117
+ yield pattern
118
+
119
+
120
+ def _find_model(hint, config):
121
+ """
122
+ Find model by name using exact and fuzzy matching.
123
+
124
+ Args:
125
+ hint: The model name hint extracted from URL
126
+ config: Sidebar configuration dictionary
127
+
128
+ Returns:
129
+ Model class or None if not found
130
+ """
131
+ all_models = apps.get_models()
132
+
133
+ # Build lookup excluding configured apps
134
+ model_names = {}
135
+ for m in all_models:
136
+ if m._meta.app_label not in config['EXCLUDE_APPS']:
137
+ model_names[m.__name__.lower()] = m
138
+
139
+ hint_lower = hint.lower()
140
+
141
+ # Exact match first
142
+ if hint_lower in model_names:
143
+ return model_names[hint_lower]
144
+
145
+ # Handle common pluralization (simple 's' suffix)
146
+ if hint_lower.endswith('s') and hint_lower[:-1] in model_names:
147
+ return model_names[hint_lower[:-1]]
148
+
149
+ # Fuzzy match as fallback
150
+ matches = get_close_matches(hint_lower, model_names.keys(), n=1, cutoff=0.8)
151
+ return model_names[matches[0]] if matches else None
@@ -0,0 +1,165 @@
1
+
2
+ .current-theme-indicator {
3
+ width: 25px;
4
+ height: 25px;
5
+ border-radius: 50%;
6
+ cursor: pointer;
7
+ border: 2px solid white;
8
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
9
+ transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
10
+ margin: 5px; /* Spacing from bottom-left corner */
11
+ justify-self: end;
12
+ display: flex;
13
+ align-items: center;
14
+ justify-content: center;
15
+ pointer-events: auto; /* Enable clicks since parent toolbar is none */
16
+ }
17
+
18
+ .theme-arrow {
19
+ position: absolute;
20
+ bottom: 36px; /* Space above indicator */
21
+ left: 50%;
22
+ transform: translateX(-50%);
23
+ color: var(--primal);
24
+ font-size: 1.3rem; /* Even bigger arrow */
25
+ opacity: 0;
26
+ visibility: hidden;
27
+ transition: all 0.2s ease;
28
+ pointer-events: none;
29
+ z-index: 10;
30
+ }
31
+
32
+ .sidebar.collapsed .theme-arrow.visible {
33
+ opacity: 1;
34
+ visibility: visible;
35
+ }
36
+
37
+ .sidebar.collapsed .theme-arrow {
38
+ bottom: 36px;
39
+ left: 50%;
40
+ transform: translateX(-50%);
41
+ }
42
+
43
+ .sidebar.collapsed .current-theme-indicator {
44
+ margin: 5px auto; /* Centered in narrow sidebar */
45
+ }
46
+
47
+ .current-theme-indicator:hover {
48
+ transform: scale(1.15);
49
+ box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2);
50
+ }
51
+
52
+ /* Theme Popup Menu */
53
+ .theme-popup {
54
+ display: none;
55
+ position: absolute;
56
+ bottom: 45px;
57
+ left: 10px; /* Fixed to left in RTL too for specific bottom-left request */
58
+ background: white;
59
+ border-radius: 12px;
60
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
61
+ padding: 10px;
62
+ z-index: 1100;
63
+ min-width: 110px;
64
+ border: 1px solid rgba(0, 0, 0, 0.08);
65
+ animation: popupIn 0.2s ease-out;
66
+ pointer-events: auto;
67
+ }
68
+
69
+ .sidebar.collapsed .theme-popup {
70
+ left: 5px;
71
+ right: 5px;
72
+ bottom: 65px; /* More space to accommodate larger arrow and breathing room */
73
+ min-width: auto;
74
+ width: auto;
75
+ background: transparent !important;
76
+ border: none !important;
77
+ box-shadow: none !important;
78
+ padding: 0 !important;
79
+ }
80
+
81
+ .sidebar.collapsed .theme-popup .small {
82
+ display: none; /* Hide "Select Color" text in narrow view */
83
+ }
84
+
85
+ .sidebar.collapsed .theme-options-grid {
86
+ grid-template-columns: 1fr; /* Vertical column */
87
+ gap: 8px;
88
+ justify-items: center;
89
+ }
90
+
91
+ /* Staggered Animations for items */
92
+ .theme-option-circle {
93
+ opacity: 0;
94
+ transform: translateY(10px);
95
+ transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
96
+ }
97
+
98
+ .theme-popup.show .theme-option-circle {
99
+ opacity: 1;
100
+ transform: translateY(0);
101
+ }
102
+
103
+ /* Staggered delays */
104
+ .theme-popup.show .theme-option-circle:nth-child(1) { transition-delay: 0.05s; }
105
+ .theme-popup.show .theme-option-circle:nth-child(2) { transition-delay: 0.1s; }
106
+ .theme-popup.show .theme-option-circle:nth-child(3) { transition-delay: 0.15s; }
107
+ .theme-popup.show .theme-option-circle:nth-child(4) { transition-delay: 0.2s; }
108
+ .theme-popup.show .theme-option-circle:nth-child(5) { transition-delay: 0.25s; }
109
+ .theme-popup.show .theme-option-circle:nth-child(6) { transition-delay: 0.3s; }
110
+
111
+ @keyframes popupIn {
112
+ from { opacity: 0; transform: translateY(10px); }
113
+ to { opacity: 1; transform: translateY(0); }
114
+ }
115
+
116
+ .theme-popup.show {
117
+ display: block;
118
+ }
119
+
120
+ .theme-options-grid {
121
+ display: grid;
122
+ grid-template-columns: repeat(3, 1fr);
123
+ gap: 12px;
124
+ }
125
+
126
+ .theme-option-circle {
127
+ width: 25px;
128
+ height: 25px;
129
+ border-radius: 50%;
130
+ cursor: pointer;
131
+ border: 2px solid transparent;
132
+ transition: all 0.2s ease;
133
+ box-shadow: inset 0 0 0 1px rgba(0,0,0,0.1);
134
+ pointer-events: auto;
135
+ }
136
+
137
+ .theme-option-circle:hover {
138
+ transform: scale(1.2);
139
+ }
140
+
141
+ .theme-option-circle.active {
142
+ border-color: #3b82f6;
143
+ box-shadow: 0 0 0 2px white, 0 0 0 4px #3b82f6 !important;
144
+ }
145
+
146
+ /* Theme Colors for circles */
147
+ .theme-circle-light { background: #f6f7f9; }
148
+ .theme-circle-blue { background: #2363c3; }
149
+ .theme-circle-gold { background: #d97706; }
150
+ .theme-circle-green { background: #166534; }
151
+ .theme-circle-red { background: #7f1d1d; }
152
+ .theme-circle-dark { background: #0f172a; }
153
+
154
+ /* Dark Mode Overrides for the Popup */
155
+ :root.theme-dark .theme-popup {
156
+ background: #1e293b;
157
+ border-color: rgba(255, 255, 255, 0.1);
158
+ color: white;
159
+ }
160
+ :root.theme-dark .sidebar-toolbar {
161
+ border-top-color: rgba(255, 255, 255, 0.05);
162
+ }
163
+ :root.theme-dark .theme-option-circle.active {
164
+ box-shadow: 0 0 0 2px #1e293b, 0 0 0 4px #3b82f6 !important;
165
+ }
@@ -0,0 +1,58 @@
1
+ (function() {
2
+ document.addEventListener('DOMContentLoaded', () => {
3
+ const indicator = document.getElementById('sidebarThemeIndicator');
4
+ const popup = document.getElementById('sidebarThemePopup');
5
+ const options = document.querySelectorAll('.theme-option-circle');
6
+ const arrow = document.getElementById('sidebarThemeArrow');
7
+
8
+ if (!indicator || !popup) return;
9
+
10
+ // Toggle Popup
11
+ indicator.addEventListener('click', (e) => {
12
+ e.stopPropagation();
13
+ const isOpen = popup.classList.toggle('show');
14
+ indicator.classList.toggle('open', isOpen);
15
+ if (arrow) arrow.classList.toggle('visible', isOpen);
16
+ });
17
+
18
+ // Close when clicking outside
19
+ document.addEventListener('click', (e) => {
20
+ if (!popup.contains(e.target) && e.target !== indicator) {
21
+ popup.classList.remove('show');
22
+ indicator.classList.remove('open');
23
+ if (arrow) arrow.classList.remove('visible');
24
+ }
25
+ });
26
+
27
+ // Theme selection
28
+ options.forEach(opt => {
29
+ opt.addEventListener('click', () => {
30
+ const theme = opt.getAttribute('data-theme');
31
+ if (window.setTheme) {
32
+ window.setTheme(theme);
33
+ updateCurrentThemeIndicator(theme);
34
+ popup.classList.remove('show');
35
+ indicator.classList.remove('open');
36
+ if (arrow) arrow.classList.remove('visible');
37
+ }
38
+ });
39
+ });
40
+
41
+ function updateCurrentThemeIndicator(theme) {
42
+ // Update the main indicator circle's color class
43
+ indicator.className = 'current-theme-indicator theme-circle-' + (theme || 'light');
44
+
45
+ // Highlight active option in popup
46
+ options.forEach(opt => {
47
+ opt.classList.remove('active');
48
+ if (opt.getAttribute('data-theme') === (theme || 'light')) {
49
+ opt.classList.add('active');
50
+ }
51
+ });
52
+ }
53
+
54
+ // Initialize indicator color
55
+ const savedTheme = localStorage.getItem('appTheme') || 'light';
56
+ updateCurrentThemeIndicator(savedTheme);
57
+ });
58
+ })();