violit 0.0.4.post1__py3-none-any.whl → 0.0.5__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.
- violit/app.py +2229 -1988
- violit/context.py +1 -0
- violit/state.py +33 -0
- violit/widgets/__init__.py +30 -30
- violit/widgets/card_widgets.py +595 -595
- violit/widgets/data_widgets.py +529 -529
- violit/widgets/layout_widgets.py +419 -419
- violit/widgets/text_widgets.py +413 -413
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/METADATA +1 -1
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/RECORD +13 -13
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/WHEEL +0 -0
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/licenses/LICENSE +0 -0
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/top_level.txt +0 -0
violit/app.py
CHANGED
|
@@ -1,1988 +1,2229 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Violit - A Streamlit-like framework with reactive components
|
|
3
|
-
Refactored with modular widget mixins
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import uuid
|
|
7
|
-
import sys
|
|
8
|
-
import argparse
|
|
9
|
-
import threading
|
|
10
|
-
import time
|
|
11
|
-
import json
|
|
12
|
-
import warnings
|
|
13
|
-
import secrets
|
|
14
|
-
import hmac
|
|
15
|
-
import hashlib
|
|
16
|
-
from typing import Any, Callable, Dict, List, Optional, Set, Union
|
|
17
|
-
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
|
18
|
-
from fastapi.responses import HTMLResponse
|
|
19
|
-
import inspect
|
|
20
|
-
import uvicorn
|
|
21
|
-
import webview
|
|
22
|
-
import pandas as pd
|
|
23
|
-
import os
|
|
24
|
-
import subprocess
|
|
25
|
-
from pathlib import Path
|
|
26
|
-
import plotly.graph_objects as go
|
|
27
|
-
import plotly.io as pio
|
|
28
|
-
|
|
29
|
-
from .context import session_ctx, rendering_ctx, fragment_ctx, app_instance_ref, layout_ctx
|
|
30
|
-
from .theme import Theme
|
|
31
|
-
from .component import Component
|
|
32
|
-
from .engine import LiteEngine, WsEngine
|
|
33
|
-
from .state import State, get_session_store
|
|
34
|
-
from .broadcast import Broadcaster
|
|
35
|
-
|
|
36
|
-
# Import all widget mixins
|
|
37
|
-
from .widgets import (
|
|
38
|
-
TextWidgetsMixin,
|
|
39
|
-
InputWidgetsMixin,
|
|
40
|
-
DataWidgetsMixin,
|
|
41
|
-
ChartWidgetsMixin,
|
|
42
|
-
MediaWidgetsMixin,
|
|
43
|
-
LayoutWidgetsMixin,
|
|
44
|
-
StatusWidgetsMixin,
|
|
45
|
-
FormWidgetsMixin,
|
|
46
|
-
ChatWidgetsMixin,
|
|
47
|
-
CardWidgetsMixin,
|
|
48
|
-
ListWidgetsMixin,
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
class FileWatcher:
|
|
52
|
-
"""Simple file watcher to detect changes"""
|
|
53
|
-
def __init__(self, debug_mode=False):
|
|
54
|
-
self.mtimes = {}
|
|
55
|
-
self.initialized = False
|
|
56
|
-
self.ignore_dirs = {'.git', '__pycache__', 'venv', '.venv', 'env', 'node_modules', '.idea', '.vscode'}
|
|
57
|
-
self.debug_mode = debug_mode
|
|
58
|
-
self.scan()
|
|
59
|
-
|
|
60
|
-
def _is_ignored(self, path: Path):
|
|
61
|
-
for part in path.parts:
|
|
62
|
-
if part in self.ignore_dirs:
|
|
63
|
-
return True
|
|
64
|
-
return False
|
|
65
|
-
|
|
66
|
-
def scan(self):
|
|
67
|
-
"""Scan current directory for py files and their mtimes"""
|
|
68
|
-
for p in Path(".").rglob("*.py"):
|
|
69
|
-
if self._is_ignored(p): continue
|
|
70
|
-
try:
|
|
71
|
-
# Use absolute path to ensure consistency
|
|
72
|
-
abs_p = p.resolve()
|
|
73
|
-
self.mtimes[abs_p] = abs_p.stat().st_mtime
|
|
74
|
-
except OSError:
|
|
75
|
-
pass
|
|
76
|
-
|
|
77
|
-
def check(self):
|
|
78
|
-
"""Check if any file changed"""
|
|
79
|
-
# Re-scan to detect new files or modifications
|
|
80
|
-
# Optimization: Scan is cheap for small projects, but we could optimize.
|
|
81
|
-
# For now, simplistic scan is fine.
|
|
82
|
-
changed = False
|
|
83
|
-
for p in list(Path(".").rglob("*.py")):
|
|
84
|
-
if self._is_ignored(p): continue
|
|
85
|
-
try:
|
|
86
|
-
abs_p = p.resolve()
|
|
87
|
-
mtime = abs_p.stat().st_mtime
|
|
88
|
-
|
|
89
|
-
if abs_p not in self.mtimes:
|
|
90
|
-
self.mtimes[abs_p] = mtime
|
|
91
|
-
# Only print if this isn't the first check (initialized)
|
|
92
|
-
# Use sys.stdout.write for immediate output
|
|
93
|
-
if self.initialized:
|
|
94
|
-
if self.debug_mode:
|
|
95
|
-
print(f"\n[HOT RELOAD] New file detected: {p}", flush=True)
|
|
96
|
-
changed = True
|
|
97
|
-
elif mtime > self.mtimes[abs_p]:
|
|
98
|
-
self.mtimes[abs_p] = mtime
|
|
99
|
-
if self.initialized:
|
|
100
|
-
if self.debug_mode:
|
|
101
|
-
print(f"\n[HOT RELOAD] File changed: {p}", flush=True)
|
|
102
|
-
changed = True
|
|
103
|
-
except OSError:
|
|
104
|
-
pass
|
|
105
|
-
|
|
106
|
-
self.initialized = True
|
|
107
|
-
return changed
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
class SidebarProxy:
|
|
111
|
-
"""Proxy for sidebar context"""
|
|
112
|
-
def __init__(self, app):
|
|
113
|
-
self.app = app
|
|
114
|
-
def __enter__(self):
|
|
115
|
-
self.token = layout_ctx.set("sidebar")
|
|
116
|
-
return self
|
|
117
|
-
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
118
|
-
layout_ctx.reset(self.token)
|
|
119
|
-
def __getattr__(self, name):
|
|
120
|
-
attr = getattr(self.app, name)
|
|
121
|
-
if callable(attr):
|
|
122
|
-
def wrapper(*args, **kwargs):
|
|
123
|
-
token = layout_ctx.set("sidebar")
|
|
124
|
-
try:
|
|
125
|
-
return attr(*args, **kwargs)
|
|
126
|
-
finally:
|
|
127
|
-
layout_ctx.reset(token)
|
|
128
|
-
return wrapper
|
|
129
|
-
return attr
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
class Page:
|
|
133
|
-
"""Represents a page in multi-page app"""
|
|
134
|
-
def __init__(self, entry_point, title=None, icon=None, url_path=None):
|
|
135
|
-
self.entry_point = entry_point
|
|
136
|
-
self.title = title or entry_point.__name__.replace("_", " ").title()
|
|
137
|
-
self.icon = icon
|
|
138
|
-
self.url_path = url_path or self.title.lower().replace(" ", "-")
|
|
139
|
-
self.key = f"page_{self.url_path}"
|
|
140
|
-
|
|
141
|
-
def run(self):
|
|
142
|
-
self.entry_point()
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
class App(
|
|
146
|
-
TextWidgetsMixin,
|
|
147
|
-
InputWidgetsMixin,
|
|
148
|
-
DataWidgetsMixin,
|
|
149
|
-
ChartWidgetsMixin,
|
|
150
|
-
MediaWidgetsMixin,
|
|
151
|
-
LayoutWidgetsMixin,
|
|
152
|
-
StatusWidgetsMixin,
|
|
153
|
-
FormWidgetsMixin,
|
|
154
|
-
ChatWidgetsMixin,
|
|
155
|
-
CardWidgetsMixin,
|
|
156
|
-
ListWidgetsMixin,
|
|
157
|
-
):
|
|
158
|
-
"""Main Violit App class"""
|
|
159
|
-
|
|
160
|
-
def __init__(self, mode='ws', title="Violit App", theme='light', allow_selection=True, animation_mode='soft', icon=None, width=1024, height=768, on_top=True, container_width='800px'):
|
|
161
|
-
self.mode = mode
|
|
162
|
-
self.app_title = title # Renamed to avoid conflict with title() method
|
|
163
|
-
self.theme_manager = Theme(theme)
|
|
164
|
-
self.fastapi = FastAPI()
|
|
165
|
-
|
|
166
|
-
# Debug mode: Check for --debug flag
|
|
167
|
-
self.debug_mode = '--debug' in sys.argv
|
|
168
|
-
|
|
169
|
-
# Native mode security: Read from environment
|
|
170
|
-
self.native_token = os.environ.get("VIOLIT_NATIVE_TOKEN")
|
|
171
|
-
self.is_native_mode = bool(os.environ.get("VIOLIT_NATIVE_MODE"))
|
|
172
|
-
|
|
173
|
-
# CSRF protection (disabled in native mode for local app security)
|
|
174
|
-
self.csrf_enabled = not self.is_native_mode
|
|
175
|
-
self.csrf_secret = secrets.token_urlsafe(32)
|
|
176
|
-
|
|
177
|
-
if self.debug_mode:
|
|
178
|
-
if self.is_native_mode and self.native_token:
|
|
179
|
-
print(f"[SECURITY] Native mode detected - Token: {self.native_token[:20]}... - CSRF disabled")
|
|
180
|
-
elif self.is_native_mode:
|
|
181
|
-
print("[WARNING] Native mode flag set but no token found!")
|
|
182
|
-
|
|
183
|
-
# Icon Setup
|
|
184
|
-
self.app_icon = icon
|
|
185
|
-
if self.app_icon is None:
|
|
186
|
-
# Set default icon path
|
|
187
|
-
base_path = os.path.dirname(os.path.abspath(__file__))
|
|
188
|
-
default_icon = os.path.join(base_path, "assets", "violit_icon_sol.ico")
|
|
189
|
-
if os.path.exists(default_icon):
|
|
190
|
-
self.app_icon = default_icon
|
|
191
|
-
|
|
192
|
-
self.width = width
|
|
193
|
-
self.height = height
|
|
194
|
-
self.on_top = on_top
|
|
195
|
-
|
|
196
|
-
# Container width: numeric (px), percentage (%), or 'none' (full width)
|
|
197
|
-
if container_width == 'none' or container_width == '100%':
|
|
198
|
-
self.container_max_width = 'none'
|
|
199
|
-
elif isinstance(container_width, int):
|
|
200
|
-
self.container_max_width = f'{container_width}px'
|
|
201
|
-
else:
|
|
202
|
-
self.container_max_width = container_width
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
# Static definitions
|
|
206
|
-
self.static_builders: Dict[str, Callable] = {}
|
|
207
|
-
self.static_order: List[str] = []
|
|
208
|
-
self.static_sidebar_order: List[str] = []
|
|
209
|
-
self.static_actions: Dict[str, Callable] = {}
|
|
210
|
-
self.static_fragments: Dict[str, Callable] = {}
|
|
211
|
-
self.static_fragment_components: Dict[str, List[Any]] = {}
|
|
212
|
-
|
|
213
|
-
self.state_count = 0
|
|
214
|
-
self._fragments: Dict[str, Callable] = {}
|
|
215
|
-
|
|
216
|
-
# Broadcasting System
|
|
217
|
-
self.broadcaster = Broadcaster(self)
|
|
218
|
-
self._fragment_count = 0
|
|
219
|
-
|
|
220
|
-
# Internal theme/settings state
|
|
221
|
-
self._theme_state = self.state(self.theme_manager.mode)
|
|
222
|
-
self._selection_state = self.state(allow_selection)
|
|
223
|
-
self._animation_state = self.state(animation_mode)
|
|
224
|
-
|
|
225
|
-
self.ws_engine = WsEngine() if mode == 'ws' else None
|
|
226
|
-
self.lite_engine = LiteEngine() if mode == 'lite' else None
|
|
227
|
-
app_instance_ref[0] = self
|
|
228
|
-
|
|
229
|
-
# Register core fragments/updaters
|
|
230
|
-
self._theme_updater()
|
|
231
|
-
self._selection_updater()
|
|
232
|
-
self._animation_updater()
|
|
233
|
-
|
|
234
|
-
self._setup_routes()
|
|
235
|
-
|
|
236
|
-
@property
|
|
237
|
-
def sidebar(self):
|
|
238
|
-
"""Access sidebar context"""
|
|
239
|
-
return SidebarProxy(self)
|
|
240
|
-
|
|
241
|
-
@property
|
|
242
|
-
def engine(self):
|
|
243
|
-
"""Get current engine (WS or Lite)"""
|
|
244
|
-
return self.ws_engine if self.mode == 'ws' else self.lite_engine
|
|
245
|
-
|
|
246
|
-
def _generate_csrf_token(self, session_id: str) -> str:
|
|
247
|
-
"""Generate CSRF token for session"""
|
|
248
|
-
if not session_id:
|
|
249
|
-
return ""
|
|
250
|
-
message = f"{session_id}:{self.csrf_secret}"
|
|
251
|
-
return hmac.new(
|
|
252
|
-
self.csrf_secret.encode(),
|
|
253
|
-
message.encode(),
|
|
254
|
-
hashlib.sha256
|
|
255
|
-
).hexdigest()
|
|
256
|
-
|
|
257
|
-
def _verify_csrf_token(self, session_id: str, token: str) -> bool:
|
|
258
|
-
"""Verify CSRF token"""
|
|
259
|
-
if not self.csrf_enabled:
|
|
260
|
-
return True
|
|
261
|
-
if not session_id or not token:
|
|
262
|
-
return False
|
|
263
|
-
expected = self._generate_csrf_token(session_id)
|
|
264
|
-
return hmac.compare_digest(expected, token)
|
|
265
|
-
|
|
266
|
-
def debug_print(self, *args, **kwargs):
|
|
267
|
-
"""Print only in debug mode"""
|
|
268
|
-
if self.debug_mode:
|
|
269
|
-
print(*args, **kwargs)
|
|
270
|
-
|
|
271
|
-
def state(self, default_value, key=None) -> State:
|
|
272
|
-
"""Create a reactive state variable"""
|
|
273
|
-
if key is None:
|
|
274
|
-
# Streamlit-style: Generate stable key from caller's location
|
|
275
|
-
frame = inspect.currentframe()
|
|
276
|
-
try:
|
|
277
|
-
caller_frame = frame.f_back
|
|
278
|
-
filename = os.path.basename(caller_frame.f_code.co_filename)
|
|
279
|
-
lineno = caller_frame.f_lineno
|
|
280
|
-
# Create stable key: filename_linenumber
|
|
281
|
-
name = f"state_{filename}_{lineno}"
|
|
282
|
-
finally:
|
|
283
|
-
del frame # Avoid reference cycles
|
|
284
|
-
else:
|
|
285
|
-
name = key
|
|
286
|
-
return State(name, default_value)
|
|
287
|
-
|
|
288
|
-
def _get_next_cid(self, prefix: str) -> str:
|
|
289
|
-
"""Generate next component ID"""
|
|
290
|
-
store = get_session_store()
|
|
291
|
-
cid = f"{prefix}_{store['component_count']}"
|
|
292
|
-
store['component_count'] += 1
|
|
293
|
-
return cid
|
|
294
|
-
|
|
295
|
-
def _register_component(self, cid: str, builder: Callable, action: Optional[Callable] = None):
|
|
296
|
-
"""Register a component with builder and optional action"""
|
|
297
|
-
store = get_session_store()
|
|
298
|
-
sid = session_ctx.get()
|
|
299
|
-
|
|
300
|
-
store['builders'][cid] = builder
|
|
301
|
-
if action:
|
|
302
|
-
store['actions'][cid] = action
|
|
303
|
-
|
|
304
|
-
curr_frag = fragment_ctx.get()
|
|
305
|
-
l_ctx = layout_ctx.get()
|
|
306
|
-
|
|
307
|
-
if curr_frag:
|
|
308
|
-
# Inside a fragment
|
|
309
|
-
# IMPORTANT: Still respect sidebar context even inside fragments!
|
|
310
|
-
if l_ctx == "sidebar":
|
|
311
|
-
# Register to sidebar, not fragment
|
|
312
|
-
if sid is None:
|
|
313
|
-
self.static_builders[cid] = builder
|
|
314
|
-
if action: self.static_actions[cid] = action
|
|
315
|
-
if cid not in self.static_sidebar_order:
|
|
316
|
-
self.static_sidebar_order.append(cid)
|
|
317
|
-
else:
|
|
318
|
-
if action: store['actions'][cid] = action
|
|
319
|
-
store['sidebar_order'].append(cid)
|
|
320
|
-
else:
|
|
321
|
-
# Normal fragment component registration
|
|
322
|
-
if sid is None:
|
|
323
|
-
# Static nesting (e.g. inside columns/expander at top level)
|
|
324
|
-
if curr_frag not in self.static_fragment_components:
|
|
325
|
-
self.static_fragment_components[curr_frag] = []
|
|
326
|
-
self.static_fragment_components[curr_frag].append((cid, builder))
|
|
327
|
-
# Store action and builder for components inside fragments
|
|
328
|
-
if action:
|
|
329
|
-
self.static_actions[cid] = action
|
|
330
|
-
self.static_builders[cid] = builder
|
|
331
|
-
else:
|
|
332
|
-
# Dynamic Nesting (Runtime)
|
|
333
|
-
if curr_frag not in store['fragment_components']:
|
|
334
|
-
store['fragment_components'][curr_frag] = []
|
|
335
|
-
store['fragment_components'][curr_frag].append((cid, builder))
|
|
336
|
-
else:
|
|
337
|
-
if sid is None:
|
|
338
|
-
# Static Root Registration
|
|
339
|
-
self.static_builders[cid] = builder
|
|
340
|
-
if action: self.static_actions[cid] = action
|
|
341
|
-
|
|
342
|
-
if l_ctx == "sidebar":
|
|
343
|
-
if cid not in self.static_sidebar_order:
|
|
344
|
-
self.static_sidebar_order.append(cid)
|
|
345
|
-
else:
|
|
346
|
-
if cid not in self.static_order:
|
|
347
|
-
self.static_order.append(cid)
|
|
348
|
-
else:
|
|
349
|
-
# Dynamic Root Registration
|
|
350
|
-
if action: store['actions'][cid] = action
|
|
351
|
-
|
|
352
|
-
if l_ctx == "sidebar":
|
|
353
|
-
store['sidebar_order'].append(cid)
|
|
354
|
-
else:
|
|
355
|
-
store['order'].append(cid)
|
|
356
|
-
|
|
357
|
-
def simple_card(self, content_fn: Union[Callable, str, State]):
|
|
358
|
-
"""Display content in a simple card
|
|
359
|
-
|
|
360
|
-
Accepts State object, callable, or string content
|
|
361
|
-
"""
|
|
362
|
-
cid = self._get_next_cid("simple_card")
|
|
363
|
-
def builder():
|
|
364
|
-
token = rendering_ctx.set(cid)
|
|
365
|
-
# Handle State object, callable, or direct value
|
|
366
|
-
if isinstance(content_fn, State):
|
|
367
|
-
val = content_fn.value
|
|
368
|
-
elif callable(content_fn):
|
|
369
|
-
val = content_fn()
|
|
370
|
-
else:
|
|
371
|
-
val = content_fn
|
|
372
|
-
|
|
373
|
-
if val is None:
|
|
374
|
-
val = "_No data_"
|
|
375
|
-
|
|
376
|
-
rendering_ctx.reset(token)
|
|
377
|
-
return Component("div", id=cid, content=str(val), class_="card")
|
|
378
|
-
self._register_component(cid, builder)
|
|
379
|
-
|
|
380
|
-
def fragment(self, func: Callable) -> Callable:
|
|
381
|
-
"""Create a reactive fragment (decorator)
|
|
382
|
-
|
|
383
|
-
.. deprecated::
|
|
384
|
-
This method is deprecated. Please use alternative patterns for managing reactive content.
|
|
385
|
-
|
|
386
|
-
Always returns a wrapper that registers on call.
|
|
387
|
-
Call the decorated function to register and render it.
|
|
388
|
-
"""
|
|
389
|
-
warnings.warn(
|
|
390
|
-
"@app.fragment is deprecated and will be removed in a future version. "
|
|
391
|
-
"Please consider using alternative patterns.",
|
|
392
|
-
DeprecationWarning,
|
|
393
|
-
stacklevel=2
|
|
394
|
-
)
|
|
395
|
-
|
|
396
|
-
fid = f"fragment_{self._fragment_count}"
|
|
397
|
-
self._fragment_count += 1
|
|
398
|
-
|
|
399
|
-
# Track if already registered
|
|
400
|
-
registered = [False]
|
|
401
|
-
|
|
402
|
-
def fragment_builder():
|
|
403
|
-
token = fragment_ctx.set(fid)
|
|
404
|
-
render_token = rendering_ctx.set(fid)
|
|
405
|
-
store = get_session_store()
|
|
406
|
-
store['fragment_components'][fid] = []
|
|
407
|
-
|
|
408
|
-
# Execute fragment logic
|
|
409
|
-
func()
|
|
410
|
-
|
|
411
|
-
# Render children
|
|
412
|
-
htmls = []
|
|
413
|
-
for cid, b in store['fragment_components'][fid]:
|
|
414
|
-
htmls.append(b().render())
|
|
415
|
-
|
|
416
|
-
fragment_ctx.reset(token)
|
|
417
|
-
rendering_ctx.reset(render_token)
|
|
418
|
-
|
|
419
|
-
inner = f'<div id="{fid}" class="fragment">{" ".join(htmls)}</div>'
|
|
420
|
-
return Component("div", id=f"{fid}_wrapper", content=inner)
|
|
421
|
-
|
|
422
|
-
# Store builder
|
|
423
|
-
self.static_builders[fid] = fragment_builder
|
|
424
|
-
self.static_fragments[fid] = func
|
|
425
|
-
|
|
426
|
-
def wrapper():
|
|
427
|
-
"""Wrapper that registers fragment on first call"""
|
|
428
|
-
if registered[0]:
|
|
429
|
-
return
|
|
430
|
-
registered[0] = True
|
|
431
|
-
|
|
432
|
-
sid = session_ctx.get()
|
|
433
|
-
if sid is None:
|
|
434
|
-
# Static context: add to static_order
|
|
435
|
-
if fid not in self.static_order:
|
|
436
|
-
self.static_order.append(fid)
|
|
437
|
-
else:
|
|
438
|
-
# Dynamic context: add to dynamic order
|
|
439
|
-
self._register_component(fid, fragment_builder)
|
|
440
|
-
|
|
441
|
-
return wrapper
|
|
442
|
-
|
|
443
|
-
def reactivity(self, func: Optional[Callable] = None):
|
|
444
|
-
"""Create a reactive scope for complex control flow
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
)
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
store = get_session_store()
|
|
475
|
-
store['fragment_components'][
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
htmls
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
#
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
if
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
#
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
try:
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
#
|
|
1130
|
-
if
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
#
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
#
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
#
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
.
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
.
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
.
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
.
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
const
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1
|
+
"""
|
|
2
|
+
Violit - A Streamlit-like framework with reactive components
|
|
3
|
+
Refactored with modular widget mixins
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import uuid
|
|
7
|
+
import sys
|
|
8
|
+
import argparse
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import json
|
|
12
|
+
import warnings
|
|
13
|
+
import secrets
|
|
14
|
+
import hmac
|
|
15
|
+
import hashlib
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Union
|
|
17
|
+
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
|
18
|
+
from fastapi.responses import HTMLResponse
|
|
19
|
+
import inspect
|
|
20
|
+
import uvicorn
|
|
21
|
+
import webview
|
|
22
|
+
import pandas as pd
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
import plotly.graph_objects as go
|
|
27
|
+
import plotly.io as pio
|
|
28
|
+
|
|
29
|
+
from .context import session_ctx, rendering_ctx, fragment_ctx, app_instance_ref, layout_ctx, page_ctx
|
|
30
|
+
from .theme import Theme
|
|
31
|
+
from .component import Component
|
|
32
|
+
from .engine import LiteEngine, WsEngine
|
|
33
|
+
from .state import State, get_session_store
|
|
34
|
+
from .broadcast import Broadcaster
|
|
35
|
+
|
|
36
|
+
# Import all widget mixins
|
|
37
|
+
from .widgets import (
|
|
38
|
+
TextWidgetsMixin,
|
|
39
|
+
InputWidgetsMixin,
|
|
40
|
+
DataWidgetsMixin,
|
|
41
|
+
ChartWidgetsMixin,
|
|
42
|
+
MediaWidgetsMixin,
|
|
43
|
+
LayoutWidgetsMixin,
|
|
44
|
+
StatusWidgetsMixin,
|
|
45
|
+
FormWidgetsMixin,
|
|
46
|
+
ChatWidgetsMixin,
|
|
47
|
+
CardWidgetsMixin,
|
|
48
|
+
ListWidgetsMixin,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
class FileWatcher:
|
|
52
|
+
"""Simple file watcher to detect changes"""
|
|
53
|
+
def __init__(self, debug_mode=False):
|
|
54
|
+
self.mtimes = {}
|
|
55
|
+
self.initialized = False
|
|
56
|
+
self.ignore_dirs = {'.git', '__pycache__', 'venv', '.venv', 'env', 'node_modules', '.idea', '.vscode'}
|
|
57
|
+
self.debug_mode = debug_mode
|
|
58
|
+
self.scan()
|
|
59
|
+
|
|
60
|
+
def _is_ignored(self, path: Path):
|
|
61
|
+
for part in path.parts:
|
|
62
|
+
if part in self.ignore_dirs:
|
|
63
|
+
return True
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
def scan(self):
|
|
67
|
+
"""Scan current directory for py files and their mtimes"""
|
|
68
|
+
for p in Path(".").rglob("*.py"):
|
|
69
|
+
if self._is_ignored(p): continue
|
|
70
|
+
try:
|
|
71
|
+
# Use absolute path to ensure consistency
|
|
72
|
+
abs_p = p.resolve()
|
|
73
|
+
self.mtimes[abs_p] = abs_p.stat().st_mtime
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def check(self):
|
|
78
|
+
"""Check if any file changed"""
|
|
79
|
+
# Re-scan to detect new files or modifications
|
|
80
|
+
# Optimization: Scan is cheap for small projects, but we could optimize.
|
|
81
|
+
# For now, simplistic scan is fine.
|
|
82
|
+
changed = False
|
|
83
|
+
for p in list(Path(".").rglob("*.py")):
|
|
84
|
+
if self._is_ignored(p): continue
|
|
85
|
+
try:
|
|
86
|
+
abs_p = p.resolve()
|
|
87
|
+
mtime = abs_p.stat().st_mtime
|
|
88
|
+
|
|
89
|
+
if abs_p not in self.mtimes:
|
|
90
|
+
self.mtimes[abs_p] = mtime
|
|
91
|
+
# Only print if this isn't the first check (initialized)
|
|
92
|
+
# Use sys.stdout.write for immediate output
|
|
93
|
+
if self.initialized:
|
|
94
|
+
if self.debug_mode:
|
|
95
|
+
print(f"\n[HOT RELOAD] New file detected: {p}", flush=True)
|
|
96
|
+
changed = True
|
|
97
|
+
elif mtime > self.mtimes[abs_p]:
|
|
98
|
+
self.mtimes[abs_p] = mtime
|
|
99
|
+
if self.initialized:
|
|
100
|
+
if self.debug_mode:
|
|
101
|
+
print(f"\n[HOT RELOAD] File changed: {p}", flush=True)
|
|
102
|
+
changed = True
|
|
103
|
+
except OSError:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
self.initialized = True
|
|
107
|
+
return changed
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class SidebarProxy:
|
|
111
|
+
"""Proxy for sidebar context"""
|
|
112
|
+
def __init__(self, app):
|
|
113
|
+
self.app = app
|
|
114
|
+
def __enter__(self):
|
|
115
|
+
self.token = layout_ctx.set("sidebar")
|
|
116
|
+
return self
|
|
117
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
118
|
+
layout_ctx.reset(self.token)
|
|
119
|
+
def __getattr__(self, name):
|
|
120
|
+
attr = getattr(self.app, name)
|
|
121
|
+
if callable(attr):
|
|
122
|
+
def wrapper(*args, **kwargs):
|
|
123
|
+
token = layout_ctx.set("sidebar")
|
|
124
|
+
try:
|
|
125
|
+
return attr(*args, **kwargs)
|
|
126
|
+
finally:
|
|
127
|
+
layout_ctx.reset(token)
|
|
128
|
+
return wrapper
|
|
129
|
+
return attr
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Page:
|
|
133
|
+
"""Represents a page in multi-page app"""
|
|
134
|
+
def __init__(self, entry_point, title=None, icon=None, url_path=None):
|
|
135
|
+
self.entry_point = entry_point
|
|
136
|
+
self.title = title or entry_point.__name__.replace("_", " ").title()
|
|
137
|
+
self.icon = icon
|
|
138
|
+
self.url_path = url_path or self.title.lower().replace(" ", "-")
|
|
139
|
+
self.key = f"page_{self.url_path}"
|
|
140
|
+
|
|
141
|
+
def run(self):
|
|
142
|
+
self.entry_point()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class App(
|
|
146
|
+
TextWidgetsMixin,
|
|
147
|
+
InputWidgetsMixin,
|
|
148
|
+
DataWidgetsMixin,
|
|
149
|
+
ChartWidgetsMixin,
|
|
150
|
+
MediaWidgetsMixin,
|
|
151
|
+
LayoutWidgetsMixin,
|
|
152
|
+
StatusWidgetsMixin,
|
|
153
|
+
FormWidgetsMixin,
|
|
154
|
+
ChatWidgetsMixin,
|
|
155
|
+
CardWidgetsMixin,
|
|
156
|
+
ListWidgetsMixin,
|
|
157
|
+
):
|
|
158
|
+
"""Main Violit App class"""
|
|
159
|
+
|
|
160
|
+
def __init__(self, mode='ws', title="Violit App", theme='light', allow_selection=True, animation_mode='soft', icon=None, width=1024, height=768, on_top=True, container_width='800px'):
|
|
161
|
+
self.mode = mode
|
|
162
|
+
self.app_title = title # Renamed to avoid conflict with title() method
|
|
163
|
+
self.theme_manager = Theme(theme)
|
|
164
|
+
self.fastapi = FastAPI()
|
|
165
|
+
|
|
166
|
+
# Debug mode: Check for --debug flag
|
|
167
|
+
self.debug_mode = '--debug' in sys.argv
|
|
168
|
+
|
|
169
|
+
# Native mode security: Read from environment
|
|
170
|
+
self.native_token = os.environ.get("VIOLIT_NATIVE_TOKEN")
|
|
171
|
+
self.is_native_mode = bool(os.environ.get("VIOLIT_NATIVE_MODE"))
|
|
172
|
+
|
|
173
|
+
# CSRF protection (disabled in native mode for local app security)
|
|
174
|
+
self.csrf_enabled = not self.is_native_mode
|
|
175
|
+
self.csrf_secret = secrets.token_urlsafe(32)
|
|
176
|
+
|
|
177
|
+
if self.debug_mode:
|
|
178
|
+
if self.is_native_mode and self.native_token:
|
|
179
|
+
print(f"[SECURITY] Native mode detected - Token: {self.native_token[:20]}... - CSRF disabled")
|
|
180
|
+
elif self.is_native_mode:
|
|
181
|
+
print("[WARNING] Native mode flag set but no token found!")
|
|
182
|
+
|
|
183
|
+
# Icon Setup
|
|
184
|
+
self.app_icon = icon
|
|
185
|
+
if self.app_icon is None:
|
|
186
|
+
# Set default icon path
|
|
187
|
+
base_path = os.path.dirname(os.path.abspath(__file__))
|
|
188
|
+
default_icon = os.path.join(base_path, "assets", "violit_icon_sol.ico")
|
|
189
|
+
if os.path.exists(default_icon):
|
|
190
|
+
self.app_icon = default_icon
|
|
191
|
+
|
|
192
|
+
self.width = width
|
|
193
|
+
self.height = height
|
|
194
|
+
self.on_top = on_top
|
|
195
|
+
|
|
196
|
+
# Container width: numeric (px), percentage (%), or 'none' (full width)
|
|
197
|
+
if container_width == 'none' or container_width == '100%':
|
|
198
|
+
self.container_max_width = 'none'
|
|
199
|
+
elif isinstance(container_width, int):
|
|
200
|
+
self.container_max_width = f'{container_width}px'
|
|
201
|
+
else:
|
|
202
|
+
self.container_max_width = container_width
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# Static definitions
|
|
206
|
+
self.static_builders: Dict[str, Callable] = {}
|
|
207
|
+
self.static_order: List[str] = []
|
|
208
|
+
self.static_sidebar_order: List[str] = []
|
|
209
|
+
self.static_actions: Dict[str, Callable] = {}
|
|
210
|
+
self.static_fragments: Dict[str, Callable] = {}
|
|
211
|
+
self.static_fragment_components: Dict[str, List[Any]] = {}
|
|
212
|
+
|
|
213
|
+
self.state_count = 0
|
|
214
|
+
self._fragments: Dict[str, Callable] = {}
|
|
215
|
+
|
|
216
|
+
# Broadcasting System
|
|
217
|
+
self.broadcaster = Broadcaster(self)
|
|
218
|
+
self._fragment_count = 0
|
|
219
|
+
|
|
220
|
+
# Internal theme/settings state
|
|
221
|
+
self._theme_state = self.state(self.theme_manager.mode)
|
|
222
|
+
self._selection_state = self.state(allow_selection)
|
|
223
|
+
self._animation_state = self.state(animation_mode)
|
|
224
|
+
|
|
225
|
+
self.ws_engine = WsEngine() if mode == 'ws' else None
|
|
226
|
+
self.lite_engine = LiteEngine() if mode == 'lite' else None
|
|
227
|
+
app_instance_ref[0] = self
|
|
228
|
+
|
|
229
|
+
# Register core fragments/updaters
|
|
230
|
+
self._theme_updater()
|
|
231
|
+
self._selection_updater()
|
|
232
|
+
self._animation_updater()
|
|
233
|
+
|
|
234
|
+
self._setup_routes()
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def sidebar(self):
|
|
238
|
+
"""Access sidebar context"""
|
|
239
|
+
return SidebarProxy(self)
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def engine(self):
|
|
243
|
+
"""Get current engine (WS or Lite)"""
|
|
244
|
+
return self.ws_engine if self.mode == 'ws' else self.lite_engine
|
|
245
|
+
|
|
246
|
+
def _generate_csrf_token(self, session_id: str) -> str:
|
|
247
|
+
"""Generate CSRF token for session"""
|
|
248
|
+
if not session_id:
|
|
249
|
+
return ""
|
|
250
|
+
message = f"{session_id}:{self.csrf_secret}"
|
|
251
|
+
return hmac.new(
|
|
252
|
+
self.csrf_secret.encode(),
|
|
253
|
+
message.encode(),
|
|
254
|
+
hashlib.sha256
|
|
255
|
+
).hexdigest()
|
|
256
|
+
|
|
257
|
+
def _verify_csrf_token(self, session_id: str, token: str) -> bool:
|
|
258
|
+
"""Verify CSRF token"""
|
|
259
|
+
if not self.csrf_enabled:
|
|
260
|
+
return True
|
|
261
|
+
if not session_id or not token:
|
|
262
|
+
return False
|
|
263
|
+
expected = self._generate_csrf_token(session_id)
|
|
264
|
+
return hmac.compare_digest(expected, token)
|
|
265
|
+
|
|
266
|
+
def debug_print(self, *args, **kwargs):
|
|
267
|
+
"""Print only in debug mode"""
|
|
268
|
+
if self.debug_mode:
|
|
269
|
+
print(*args, **kwargs)
|
|
270
|
+
|
|
271
|
+
def state(self, default_value, key=None) -> State:
|
|
272
|
+
"""Create a reactive state variable"""
|
|
273
|
+
if key is None:
|
|
274
|
+
# Streamlit-style: Generate stable key from caller's location
|
|
275
|
+
frame = inspect.currentframe()
|
|
276
|
+
try:
|
|
277
|
+
caller_frame = frame.f_back
|
|
278
|
+
filename = os.path.basename(caller_frame.f_code.co_filename)
|
|
279
|
+
lineno = caller_frame.f_lineno
|
|
280
|
+
# Create stable key: filename_linenumber
|
|
281
|
+
name = f"state_{filename}_{lineno}"
|
|
282
|
+
finally:
|
|
283
|
+
del frame # Avoid reference cycles
|
|
284
|
+
else:
|
|
285
|
+
name = key
|
|
286
|
+
return State(name, default_value)
|
|
287
|
+
|
|
288
|
+
def _get_next_cid(self, prefix: str) -> str:
|
|
289
|
+
"""Generate next component ID"""
|
|
290
|
+
store = get_session_store()
|
|
291
|
+
cid = f"{prefix}_{store['component_count']}"
|
|
292
|
+
store['component_count'] += 1
|
|
293
|
+
return cid
|
|
294
|
+
|
|
295
|
+
def _register_component(self, cid: str, builder: Callable, action: Optional[Callable] = None):
|
|
296
|
+
"""Register a component with builder and optional action"""
|
|
297
|
+
store = get_session_store()
|
|
298
|
+
sid = session_ctx.get()
|
|
299
|
+
|
|
300
|
+
store['builders'][cid] = builder
|
|
301
|
+
if action:
|
|
302
|
+
store['actions'][cid] = action
|
|
303
|
+
|
|
304
|
+
curr_frag = fragment_ctx.get()
|
|
305
|
+
l_ctx = layout_ctx.get()
|
|
306
|
+
|
|
307
|
+
if curr_frag:
|
|
308
|
+
# Inside a fragment
|
|
309
|
+
# IMPORTANT: Still respect sidebar context even inside fragments!
|
|
310
|
+
if l_ctx == "sidebar":
|
|
311
|
+
# Register to sidebar, not fragment
|
|
312
|
+
if sid is None:
|
|
313
|
+
self.static_builders[cid] = builder
|
|
314
|
+
if action: self.static_actions[cid] = action
|
|
315
|
+
if cid not in self.static_sidebar_order:
|
|
316
|
+
self.static_sidebar_order.append(cid)
|
|
317
|
+
else:
|
|
318
|
+
if action: store['actions'][cid] = action
|
|
319
|
+
store['sidebar_order'].append(cid)
|
|
320
|
+
else:
|
|
321
|
+
# Normal fragment component registration
|
|
322
|
+
if sid is None:
|
|
323
|
+
# Static nesting (e.g. inside columns/expander at top level)
|
|
324
|
+
if curr_frag not in self.static_fragment_components:
|
|
325
|
+
self.static_fragment_components[curr_frag] = []
|
|
326
|
+
self.static_fragment_components[curr_frag].append((cid, builder))
|
|
327
|
+
# Store action and builder for components inside fragments
|
|
328
|
+
if action:
|
|
329
|
+
self.static_actions[cid] = action
|
|
330
|
+
self.static_builders[cid] = builder
|
|
331
|
+
else:
|
|
332
|
+
# Dynamic Nesting (Runtime)
|
|
333
|
+
if curr_frag not in store['fragment_components']:
|
|
334
|
+
store['fragment_components'][curr_frag] = []
|
|
335
|
+
store['fragment_components'][curr_frag].append((cid, builder))
|
|
336
|
+
else:
|
|
337
|
+
if sid is None:
|
|
338
|
+
# Static Root Registration
|
|
339
|
+
self.static_builders[cid] = builder
|
|
340
|
+
if action: self.static_actions[cid] = action
|
|
341
|
+
|
|
342
|
+
if l_ctx == "sidebar":
|
|
343
|
+
if cid not in self.static_sidebar_order:
|
|
344
|
+
self.static_sidebar_order.append(cid)
|
|
345
|
+
else:
|
|
346
|
+
if cid not in self.static_order:
|
|
347
|
+
self.static_order.append(cid)
|
|
348
|
+
else:
|
|
349
|
+
# Dynamic Root Registration
|
|
350
|
+
if action: store['actions'][cid] = action
|
|
351
|
+
|
|
352
|
+
if l_ctx == "sidebar":
|
|
353
|
+
store['sidebar_order'].append(cid)
|
|
354
|
+
else:
|
|
355
|
+
store['order'].append(cid)
|
|
356
|
+
|
|
357
|
+
def simple_card(self, content_fn: Union[Callable, str, State]):
|
|
358
|
+
"""Display content in a simple card
|
|
359
|
+
|
|
360
|
+
Accepts State object, callable, or string content
|
|
361
|
+
"""
|
|
362
|
+
cid = self._get_next_cid("simple_card")
|
|
363
|
+
def builder():
|
|
364
|
+
token = rendering_ctx.set(cid)
|
|
365
|
+
# Handle State object, callable, or direct value
|
|
366
|
+
if isinstance(content_fn, State):
|
|
367
|
+
val = content_fn.value
|
|
368
|
+
elif callable(content_fn):
|
|
369
|
+
val = content_fn()
|
|
370
|
+
else:
|
|
371
|
+
val = content_fn
|
|
372
|
+
|
|
373
|
+
if val is None:
|
|
374
|
+
val = "_No data_"
|
|
375
|
+
|
|
376
|
+
rendering_ctx.reset(token)
|
|
377
|
+
return Component("div", id=cid, content=str(val), class_="card")
|
|
378
|
+
self._register_component(cid, builder)
|
|
379
|
+
|
|
380
|
+
def fragment(self, func: Callable) -> Callable:
|
|
381
|
+
"""Create a reactive fragment (decorator)
|
|
382
|
+
|
|
383
|
+
.. deprecated::
|
|
384
|
+
This method is deprecated. Please use alternative patterns for managing reactive content.
|
|
385
|
+
|
|
386
|
+
Always returns a wrapper that registers on call.
|
|
387
|
+
Call the decorated function to register and render it.
|
|
388
|
+
"""
|
|
389
|
+
warnings.warn(
|
|
390
|
+
"@app.fragment is deprecated and will be removed in a future version. "
|
|
391
|
+
"Please consider using alternative patterns.",
|
|
392
|
+
DeprecationWarning,
|
|
393
|
+
stacklevel=2
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
fid = f"fragment_{self._fragment_count}"
|
|
397
|
+
self._fragment_count += 1
|
|
398
|
+
|
|
399
|
+
# Track if already registered
|
|
400
|
+
registered = [False]
|
|
401
|
+
|
|
402
|
+
def fragment_builder():
|
|
403
|
+
token = fragment_ctx.set(fid)
|
|
404
|
+
render_token = rendering_ctx.set(fid)
|
|
405
|
+
store = get_session_store()
|
|
406
|
+
store['fragment_components'][fid] = []
|
|
407
|
+
|
|
408
|
+
# Execute fragment logic
|
|
409
|
+
func()
|
|
410
|
+
|
|
411
|
+
# Render children
|
|
412
|
+
htmls = []
|
|
413
|
+
for cid, b in store['fragment_components'][fid]:
|
|
414
|
+
htmls.append(b().render())
|
|
415
|
+
|
|
416
|
+
fragment_ctx.reset(token)
|
|
417
|
+
rendering_ctx.reset(render_token)
|
|
418
|
+
|
|
419
|
+
inner = f'<div id="{fid}" class="fragment">{" ".join(htmls)}</div>'
|
|
420
|
+
return Component("div", id=f"{fid}_wrapper", content=inner)
|
|
421
|
+
|
|
422
|
+
# Store builder
|
|
423
|
+
self.static_builders[fid] = fragment_builder
|
|
424
|
+
self.static_fragments[fid] = func
|
|
425
|
+
|
|
426
|
+
def wrapper():
|
|
427
|
+
"""Wrapper that registers fragment on first call"""
|
|
428
|
+
if registered[0]:
|
|
429
|
+
return
|
|
430
|
+
registered[0] = True
|
|
431
|
+
|
|
432
|
+
sid = session_ctx.get()
|
|
433
|
+
if sid is None:
|
|
434
|
+
# Static context: add to static_order
|
|
435
|
+
if fid not in self.static_order:
|
|
436
|
+
self.static_order.append(fid)
|
|
437
|
+
else:
|
|
438
|
+
# Dynamic context: add to dynamic order
|
|
439
|
+
self._register_component(fid, fragment_builder)
|
|
440
|
+
|
|
441
|
+
return wrapper
|
|
442
|
+
|
|
443
|
+
def reactivity(self, func: Optional[Callable] = None):
|
|
444
|
+
"""Create a reactive scope for complex control flow
|
|
445
|
+
|
|
446
|
+
Can be used as:
|
|
447
|
+
1. Decorator: @app.reactivity for function-wrapped reactive blocks
|
|
448
|
+
2. Context Manager: with app.reactivity(): for inline reactive blocks (triggers page rerun)
|
|
449
|
+
|
|
450
|
+
Example (Decorator - Partial Rerun):
|
|
451
|
+
@app.reactivity
|
|
452
|
+
def my_reactive_block():
|
|
453
|
+
if count.value > 5:
|
|
454
|
+
app.success("Big!")
|
|
455
|
+
my_reactive_block()
|
|
456
|
+
|
|
457
|
+
Example (Context Manager - Page Rerun):
|
|
458
|
+
with app.reactivity():
|
|
459
|
+
if count.value > 5:
|
|
460
|
+
app.success("Big!")
|
|
461
|
+
"""
|
|
462
|
+
if func is not None:
|
|
463
|
+
# Decorator mode: wrap function as a reactive fragment
|
|
464
|
+
# This enables PARTIAL RERUN of just this function
|
|
465
|
+
fid = f"reactivity_{self._fragment_count}"
|
|
466
|
+
self._fragment_count += 1
|
|
467
|
+
|
|
468
|
+
# Track if already registered
|
|
469
|
+
registered = [False]
|
|
470
|
+
|
|
471
|
+
def fragment_builder():
|
|
472
|
+
token = fragment_ctx.set(fid)
|
|
473
|
+
render_token = rendering_ctx.set(fid)
|
|
474
|
+
store = get_session_store()
|
|
475
|
+
store['fragment_components'][fid] = []
|
|
476
|
+
|
|
477
|
+
# Execute the user's function
|
|
478
|
+
func()
|
|
479
|
+
|
|
480
|
+
# Render children
|
|
481
|
+
htmls = []
|
|
482
|
+
for cid, b in store['fragment_components'][fid]:
|
|
483
|
+
htmls.append(b().render())
|
|
484
|
+
|
|
485
|
+
fragment_ctx.reset(token)
|
|
486
|
+
rendering_ctx.reset(render_token)
|
|
487
|
+
|
|
488
|
+
inner = f'<div id="{fid}" class="fragment">{" ".join(htmls)}</div>'
|
|
489
|
+
return Component("div", id=f"{fid}_wrapper", content=inner)
|
|
490
|
+
|
|
491
|
+
# Store builder
|
|
492
|
+
self.static_builders[fid] = fragment_builder
|
|
493
|
+
self.static_fragments[fid] = func
|
|
494
|
+
|
|
495
|
+
def wrapper():
|
|
496
|
+
"""Wrapper that registers fragment on first call"""
|
|
497
|
+
sid = session_ctx.get()
|
|
498
|
+
if sid is None:
|
|
499
|
+
# Static context: register once
|
|
500
|
+
if registered[0]: return
|
|
501
|
+
registered[0] = True
|
|
502
|
+
if fid not in self.static_order:
|
|
503
|
+
self.static_order.append(fid)
|
|
504
|
+
else:
|
|
505
|
+
# Dynamic context: Always register to add to current order
|
|
506
|
+
self._register_component(fid, fragment_builder)
|
|
507
|
+
|
|
508
|
+
return wrapper
|
|
509
|
+
|
|
510
|
+
# Context manager mode
|
|
511
|
+
class ReactivityContext:
|
|
512
|
+
def __init__(ctx_self, app):
|
|
513
|
+
ctx_self.app = app
|
|
514
|
+
ctx_self.fid = None
|
|
515
|
+
ctx_self.fragment_token = None
|
|
516
|
+
# DON'T create new rendering_ctx - keep parent's!
|
|
517
|
+
|
|
518
|
+
def __enter__(ctx_self):
|
|
519
|
+
# Create a temporary fragment scope for component collection
|
|
520
|
+
ctx_self.fid = f"reactivity_{self._fragment_count}"
|
|
521
|
+
self._fragment_count += 1
|
|
522
|
+
|
|
523
|
+
# Set fragment context only (state access registers with parent)
|
|
524
|
+
ctx_self.fragment_token = fragment_ctx.set(ctx_self.fid)
|
|
525
|
+
|
|
526
|
+
# IMPORTANT: If inside a page, enable subscription to the page renderer
|
|
527
|
+
# This allows if/for blocks inside with app.reactivity(): to trigger page re-runs
|
|
528
|
+
p_ctx = page_ctx.get()
|
|
529
|
+
ctx_self.rendering_token = None
|
|
530
|
+
if p_ctx:
|
|
531
|
+
ctx_self.rendering_token = rendering_ctx.set(p_ctx)
|
|
532
|
+
|
|
533
|
+
store = get_session_store()
|
|
534
|
+
store['fragment_components'][ctx_self.fid] = []
|
|
535
|
+
return ctx_self
|
|
536
|
+
|
|
537
|
+
def __exit__(ctx_self, exc_type, exc_val, exc_tb):
|
|
538
|
+
store = get_session_store()
|
|
539
|
+
|
|
540
|
+
# Build the fragment
|
|
541
|
+
def reactivity_builder():
|
|
542
|
+
htmls = []
|
|
543
|
+
for cid, b in store['fragment_components'][ctx_self.fid]:
|
|
544
|
+
htmls.append(b().render())
|
|
545
|
+
inner = f'<div id="{ctx_self.fid}" class="fragment">{" ".join(htmls)}</div>'
|
|
546
|
+
return Component("div", id=f"{ctx_self.fid}_wrapper", content=inner)
|
|
547
|
+
|
|
548
|
+
fragment_ctx.reset(ctx_self.fragment_token)
|
|
549
|
+
if ctx_self.rendering_token:
|
|
550
|
+
rendering_ctx.reset(ctx_self.rendering_token)
|
|
551
|
+
|
|
552
|
+
# Register the reactivity scope as a component
|
|
553
|
+
self._register_component(ctx_self.fid, reactivity_builder)
|
|
554
|
+
|
|
555
|
+
return ReactivityContext(self)
|
|
556
|
+
|
|
557
|
+
def If(self, condition, then_block=None, else_block=None, *, then=None, else_=None):
|
|
558
|
+
"""Reactive conditional rendering widget.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
condition: Boolean or Callable[[], bool].
|
|
562
|
+
If callable (e.g. lambda: count.value > 5), it's re-evaluated on render.
|
|
563
|
+
then_block: Function to call when True
|
|
564
|
+
else_block: Function to call when False
|
|
565
|
+
"""
|
|
566
|
+
# Resolve positional vs keyword
|
|
567
|
+
actual_then = then_block if then_block is not None else then
|
|
568
|
+
actual_else = else_block if else_block is not None else else_
|
|
569
|
+
|
|
570
|
+
cid = self._get_next_cid("if")
|
|
571
|
+
|
|
572
|
+
def if_builder():
|
|
573
|
+
# Set rendering context for dependency tracking
|
|
574
|
+
token = rendering_ctx.set(cid)
|
|
575
|
+
try:
|
|
576
|
+
# Evaluate condition dynamically
|
|
577
|
+
current_cond = condition
|
|
578
|
+
if callable(condition):
|
|
579
|
+
current_cond = condition()
|
|
580
|
+
elif hasattr(condition, 'value'):
|
|
581
|
+
current_cond = condition.value
|
|
582
|
+
|
|
583
|
+
if current_cond:
|
|
584
|
+
if actual_then:
|
|
585
|
+
store = get_session_store()
|
|
586
|
+
prev_order = store['order'].copy()
|
|
587
|
+
store['order'] = []
|
|
588
|
+
|
|
589
|
+
actual_then()
|
|
590
|
+
|
|
591
|
+
htmls = []
|
|
592
|
+
for child_cid in store['order']:
|
|
593
|
+
builder = store['builders'].get(child_cid) or self.static_builders.get(child_cid)
|
|
594
|
+
if builder:
|
|
595
|
+
htmls.append(builder().render())
|
|
596
|
+
|
|
597
|
+
store['order'] = prev_order
|
|
598
|
+
content = '\n'.join(htmls)
|
|
599
|
+
return Component("div", id=cid, content=content, class_="if-block if-then")
|
|
600
|
+
else:
|
|
601
|
+
if actual_else:
|
|
602
|
+
store = get_session_store()
|
|
603
|
+
prev_order = store['order'].copy()
|
|
604
|
+
store['order'] = []
|
|
605
|
+
|
|
606
|
+
actual_else()
|
|
607
|
+
|
|
608
|
+
htmls = []
|
|
609
|
+
for child_cid in store['order']:
|
|
610
|
+
builder = store['builders'].get(child_cid) or self.static_builders.get(child_cid)
|
|
611
|
+
if builder:
|
|
612
|
+
htmls.append(builder().render())
|
|
613
|
+
|
|
614
|
+
store['order'] = prev_order
|
|
615
|
+
content = '\n'.join(htmls)
|
|
616
|
+
return Component("div", id=cid, content=content, class_="if-block if-else")
|
|
617
|
+
|
|
618
|
+
return Component("div", id=cid, content="", class_="if-block if-empty")
|
|
619
|
+
finally:
|
|
620
|
+
rendering_ctx.reset(token)
|
|
621
|
+
|
|
622
|
+
self._register_component(cid, if_builder)
|
|
623
|
+
|
|
624
|
+
def For(self, items, render_fn=None, empty_fn=None, *, render=None, empty=None):
|
|
625
|
+
"""Reactive loop rendering widget.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
items: List, State, or Callable[[], List].
|
|
629
|
+
render_fn: Function(item) or Function(item, index)
|
|
630
|
+
empty_fn: Function when list is empty
|
|
631
|
+
"""
|
|
632
|
+
# Resolve positional vs keyword
|
|
633
|
+
actual_render = render_fn if render_fn is not None else render
|
|
634
|
+
actual_empty = empty_fn if empty_fn is not None else empty
|
|
635
|
+
|
|
636
|
+
cid = self._get_next_cid("for")
|
|
637
|
+
|
|
638
|
+
def for_builder():
|
|
639
|
+
token = rendering_ctx.set(cid)
|
|
640
|
+
try:
|
|
641
|
+
store = get_session_store()
|
|
642
|
+
|
|
643
|
+
# Evaluate items dynamically
|
|
644
|
+
current_items = items
|
|
645
|
+
if hasattr(items, 'value'): # State object
|
|
646
|
+
current_items = items.value
|
|
647
|
+
elif callable(items): # Lambda
|
|
648
|
+
current_items = items()
|
|
649
|
+
|
|
650
|
+
# Check if empty
|
|
651
|
+
if not current_items or len(current_items) == 0:
|
|
652
|
+
if actual_empty:
|
|
653
|
+
prev_order = store['order'].copy()
|
|
654
|
+
store['order'] = []
|
|
655
|
+
|
|
656
|
+
actual_empty()
|
|
657
|
+
|
|
658
|
+
htmls = []
|
|
659
|
+
for child_cid in store['order']:
|
|
660
|
+
builder = store['builders'].get(child_cid) or self.static_builders.get(child_cid)
|
|
661
|
+
if builder:
|
|
662
|
+
htmls.append(builder().render())
|
|
663
|
+
|
|
664
|
+
store['order'] = prev_order
|
|
665
|
+
content = '\n'.join(htmls)
|
|
666
|
+
return Component("div", id=cid, content=content, class_="for-block for-empty")
|
|
667
|
+
else:
|
|
668
|
+
return Component("div", id=cid, content="", class_="for-block for-empty")
|
|
669
|
+
|
|
670
|
+
# Render each item
|
|
671
|
+
if actual_render:
|
|
672
|
+
all_htmls = []
|
|
673
|
+
|
|
674
|
+
for idx, item in enumerate(current_items):
|
|
675
|
+
prev_order = store['order'].copy()
|
|
676
|
+
store['order'] = []
|
|
677
|
+
|
|
678
|
+
# Try to call with (item, index), fall back to (item)
|
|
679
|
+
import inspect
|
|
680
|
+
sig = inspect.signature(actual_render)
|
|
681
|
+
if len(sig.parameters) >= 2:
|
|
682
|
+
actual_render(item, idx)
|
|
683
|
+
else:
|
|
684
|
+
actual_render(item)
|
|
685
|
+
|
|
686
|
+
for child_cid in store['order']:
|
|
687
|
+
builder = store['builders'].get(child_cid) or self.static_builders.get(child_cid)
|
|
688
|
+
if builder:
|
|
689
|
+
all_htmls.append(builder().render())
|
|
690
|
+
|
|
691
|
+
store['order'] = prev_order
|
|
692
|
+
|
|
693
|
+
content = '\n'.join(all_htmls)
|
|
694
|
+
return Component("div", id=cid, content=content, class_="for-block")
|
|
695
|
+
|
|
696
|
+
return Component("div", id=cid, content="", class_="for-block")
|
|
697
|
+
finally:
|
|
698
|
+
rendering_ctx.reset(token)
|
|
699
|
+
|
|
700
|
+
self._register_component(cid, for_builder)
|
|
701
|
+
|
|
702
|
+
def _render_all(self):
|
|
703
|
+
"""Render all components"""
|
|
704
|
+
store = get_session_store()
|
|
705
|
+
|
|
706
|
+
main_html = []
|
|
707
|
+
sidebar_html = []
|
|
708
|
+
|
|
709
|
+
def render_cids(cids, target_list):
|
|
710
|
+
for cid in cids:
|
|
711
|
+
builder = store['builders'].get(cid) or self.static_builders.get(cid)
|
|
712
|
+
if builder:
|
|
713
|
+
target_list.append(builder().render())
|
|
714
|
+
|
|
715
|
+
# Static Components
|
|
716
|
+
render_cids(self.static_order, main_html)
|
|
717
|
+
render_cids(self.static_sidebar_order, sidebar_html)
|
|
718
|
+
|
|
719
|
+
# Dynamic Components
|
|
720
|
+
render_cids(store['order'], main_html)
|
|
721
|
+
render_cids(store['sidebar_order'], sidebar_html)
|
|
722
|
+
|
|
723
|
+
return "".join(main_html), "".join(sidebar_html)
|
|
724
|
+
|
|
725
|
+
def _get_dirty_rendered(self):
|
|
726
|
+
"""Get components that need updating"""
|
|
727
|
+
store = get_session_store()
|
|
728
|
+
dirty_states = store.get('dirty_states', set())
|
|
729
|
+
aff = set()
|
|
730
|
+
for s in dirty_states: aff.update(store['tracker'].get_dirty_components(s))
|
|
731
|
+
store['dirty_states'] = set()
|
|
732
|
+
|
|
733
|
+
res = []
|
|
734
|
+
for cid in aff:
|
|
735
|
+
builder = store['builders'].get(cid) or self.static_builders.get(cid)
|
|
736
|
+
if builder:
|
|
737
|
+
res.append(builder())
|
|
738
|
+
return res
|
|
739
|
+
|
|
740
|
+
# Theme and settings methods
|
|
741
|
+
def set_theme(self, p):
|
|
742
|
+
"""Set theme preset"""
|
|
743
|
+
import time
|
|
744
|
+
store = get_session_store()
|
|
745
|
+
store['theme'].set_preset(p)
|
|
746
|
+
if self._theme_state:
|
|
747
|
+
# Use timestamp to force dirty even if same theme selected twice
|
|
748
|
+
self._theme_state.set(f"{p}_{time.time()}")
|
|
749
|
+
|
|
750
|
+
def set_selection_mode(self, enabled: bool):
|
|
751
|
+
"""Enable/disable text selection"""
|
|
752
|
+
if self._selection_state:
|
|
753
|
+
self._selection_state.set(enabled)
|
|
754
|
+
|
|
755
|
+
def set_animation_mode(self, mode: str):
|
|
756
|
+
"""Set animation mode ('soft' or 'hard')"""
|
|
757
|
+
if self._animation_state:
|
|
758
|
+
self._animation_state.set(mode)
|
|
759
|
+
|
|
760
|
+
def set_primary_color(self, c):
|
|
761
|
+
"""Set primary theme color"""
|
|
762
|
+
store = get_session_store()
|
|
763
|
+
store['theme'].set_color('primary', c)
|
|
764
|
+
if self._theme_state:
|
|
765
|
+
self._theme_state.set(str(time.time()))
|
|
766
|
+
|
|
767
|
+
def _selection_updater(self):
|
|
768
|
+
"""Update selection mode"""
|
|
769
|
+
cid = "__selection_updater__"
|
|
770
|
+
def builder():
|
|
771
|
+
token = rendering_ctx.set(cid)
|
|
772
|
+
enabled = self._selection_state.value
|
|
773
|
+
rendering_ctx.reset(token)
|
|
774
|
+
|
|
775
|
+
action = "remove" if enabled else "add"
|
|
776
|
+
script = f"<script>document.body.classList.{action}('no-select');</script>"
|
|
777
|
+
return Component("div", id=cid, style="display:none", content=script)
|
|
778
|
+
self._register_component(cid, builder)
|
|
779
|
+
|
|
780
|
+
def _patch_webview_icon(self):
|
|
781
|
+
"""Monkey-patch pywebview's WinForms BrowserForm to use custom icon"""
|
|
782
|
+
if os.name != 'nt' or not self.app_icon:
|
|
783
|
+
return
|
|
784
|
+
|
|
785
|
+
try:
|
|
786
|
+
from webview.platforms import winforms
|
|
787
|
+
|
|
788
|
+
# Store reference to icon path for closure
|
|
789
|
+
icon_path = self.app_icon
|
|
790
|
+
|
|
791
|
+
# Check if already patched
|
|
792
|
+
if hasattr(winforms.BrowserView.BrowserForm, '_violit_patched'):
|
|
793
|
+
return
|
|
794
|
+
|
|
795
|
+
# Get original __init__
|
|
796
|
+
original_init = winforms.BrowserView.BrowserForm.__init__
|
|
797
|
+
|
|
798
|
+
def patched_init(self, window, cache_dir):
|
|
799
|
+
"""Patched __init__ that sets custom icon after original init"""
|
|
800
|
+
original_init(self, window, cache_dir)
|
|
801
|
+
|
|
802
|
+
try:
|
|
803
|
+
from System.Drawing import Icon as DotNetIcon
|
|
804
|
+
if os.path.exists(icon_path):
|
|
805
|
+
self.Icon = DotNetIcon(icon_path)
|
|
806
|
+
except Exception:
|
|
807
|
+
pass # Silently fail if icon can't be set
|
|
808
|
+
|
|
809
|
+
# Apply patch
|
|
810
|
+
winforms.BrowserView.BrowserForm.__init__ = patched_init
|
|
811
|
+
winforms.BrowserView.BrowserForm._violit_patched = True
|
|
812
|
+
|
|
813
|
+
except Exception:
|
|
814
|
+
pass # If patching fails, continue without custom icon
|
|
815
|
+
|
|
816
|
+
def _animation_updater(self):
|
|
817
|
+
"""Update animation mode"""
|
|
818
|
+
cid = "__animation_updater__"
|
|
819
|
+
def builder():
|
|
820
|
+
token = rendering_ctx.set(cid)
|
|
821
|
+
mode = self._animation_state.value
|
|
822
|
+
rendering_ctx.reset(token)
|
|
823
|
+
|
|
824
|
+
script = f"<script>document.body.classList.remove('anim-soft', 'anim-hard'); document.body.classList.add('anim-{mode}');</script>"
|
|
825
|
+
return Component("div", id=cid, style="display:none", content=script)
|
|
826
|
+
self._register_component(cid, builder)
|
|
827
|
+
|
|
828
|
+
def _theme_updater(self):
|
|
829
|
+
"""Update theme"""
|
|
830
|
+
cid = "__theme_updater__"
|
|
831
|
+
def builder():
|
|
832
|
+
token = rendering_ctx.set(cid)
|
|
833
|
+
_ = self._theme_state.value
|
|
834
|
+
rendering_ctx.reset(token)
|
|
835
|
+
|
|
836
|
+
store = get_session_store()
|
|
837
|
+
t = store['theme']
|
|
838
|
+
vars_str = t.to_css_vars()
|
|
839
|
+
cls = t.theme_class
|
|
840
|
+
|
|
841
|
+
script_content = f'''
|
|
842
|
+
<script>
|
|
843
|
+
(function() {{
|
|
844
|
+
document.documentElement.className = '{cls}';
|
|
845
|
+
const root = document.documentElement;
|
|
846
|
+
const vars = `{vars_str}`.split('\\n');
|
|
847
|
+
vars.forEach(v => {{
|
|
848
|
+
const parts = v.split(':');
|
|
849
|
+
if(parts.length === 2) {{
|
|
850
|
+
const key = parts[0].trim();
|
|
851
|
+
const val = parts[1].replace(';', '').trim();
|
|
852
|
+
root.style.setProperty(key, val);
|
|
853
|
+
}}
|
|
854
|
+
}});
|
|
855
|
+
|
|
856
|
+
// Update Extra CSS
|
|
857
|
+
let extraStyle = document.getElementById('theme-extra');
|
|
858
|
+
if (!extraStyle) {{
|
|
859
|
+
extraStyle = document.createElement('style');
|
|
860
|
+
extraStyle.id = 'theme-extra';
|
|
861
|
+
document.head.appendChild(extraStyle);
|
|
862
|
+
}}
|
|
863
|
+
extraStyle.textContent = `{t.extra_css}`;
|
|
864
|
+
}})();
|
|
865
|
+
</script>
|
|
866
|
+
'''
|
|
867
|
+
return Component("div", id=cid, style="display:none", content=script_content)
|
|
868
|
+
self._register_component(cid, builder)
|
|
869
|
+
|
|
870
|
+
def navigation(self, pages: List[Any], position="sidebar", auto_run=True, reactivity_mode=False):
|
|
871
|
+
"""Create multi-page navigation
|
|
872
|
+
|
|
873
|
+
Args:
|
|
874
|
+
pages: List of Page objects or functions
|
|
875
|
+
position: 'sidebar' or 'top' (default: sidebar)
|
|
876
|
+
auto_run: Run logic immediately (default: True)
|
|
877
|
+
reactivity_mode: If True, treats each page as a reactive scope (auto pre-evaluates).
|
|
878
|
+
This allows standard 'if' statements to be reactive.
|
|
879
|
+
"""
|
|
880
|
+
# Normalize pages
|
|
881
|
+
final_pages = []
|
|
882
|
+
for p in pages:
|
|
883
|
+
if isinstance(p, Page): final_pages.append(p)
|
|
884
|
+
elif callable(p): final_pages.append(Page(p))
|
|
885
|
+
|
|
886
|
+
if not final_pages: return None
|
|
887
|
+
|
|
888
|
+
# Singleton state for navigation
|
|
889
|
+
current_page_key_state = self.state(final_pages[0].key, key="__nav_selection__")
|
|
890
|
+
|
|
891
|
+
# Navigation Menu Builder
|
|
892
|
+
cid = self._get_next_cid("nav_menu")
|
|
893
|
+
nav_cid = cid # Capture for use in nav_action closure
|
|
894
|
+
def nav_builder():
|
|
895
|
+
token = rendering_ctx.set(cid)
|
|
896
|
+
curr = current_page_key_state.value
|
|
897
|
+
|
|
898
|
+
items = []
|
|
899
|
+
for p in final_pages:
|
|
900
|
+
is_active = p.key == curr
|
|
901
|
+
click_attr = ""
|
|
902
|
+
if self.mode == 'lite':
|
|
903
|
+
# Lite mode: update hash and HTMX post
|
|
904
|
+
page_hash = p.key.replace("page_", "")
|
|
905
|
+
click_attr = f'onclick="window.location.hash = \'{page_hash}\'" hx-post="/action/{cid}" hx-vals=\'{{"value": "{p.key}"}}\' hx-target="#{cid}" hx-swap="outerHTML"'
|
|
906
|
+
else:
|
|
907
|
+
# WebSocket mode (including native)
|
|
908
|
+
click_attr = f'onclick="window.sendAction(\'{cid}\', \'{p.key}\')"'
|
|
909
|
+
|
|
910
|
+
# Styling for active/inactive nav items
|
|
911
|
+
if is_active:
|
|
912
|
+
style = "width: 100%; justify-content: start; --sl-color-primary-500: var(--sl-primary); --sl-color-primary-600: var(--sl-primary);"
|
|
913
|
+
variant = "primary"
|
|
914
|
+
else:
|
|
915
|
+
style = "width: 100%; justify-content: start; --sl-color-neutral-700: var(--sl-text);"
|
|
916
|
+
variant = "text"
|
|
917
|
+
|
|
918
|
+
icon_html = f'<sl-icon name="{p.icon}" slot="prefix"></sl-icon> ' if p.icon else ""
|
|
919
|
+
items.append(f'<sl-button style="{style}" variant="{variant}" {click_attr}>{icon_html}{p.title}</sl-button>')
|
|
920
|
+
|
|
921
|
+
rendering_ctx.reset(token)
|
|
922
|
+
return Component("div", id=cid, content="<br>".join(items), class_="nav-container")
|
|
923
|
+
|
|
924
|
+
def nav_action(key):
|
|
925
|
+
current_page_key_state.set(key)
|
|
926
|
+
|
|
927
|
+
# Register Nav Component
|
|
928
|
+
if position == "sidebar":
|
|
929
|
+
token = layout_ctx.set("sidebar")
|
|
930
|
+
try:
|
|
931
|
+
self._register_component(cid, nav_builder, action=nav_action)
|
|
932
|
+
finally:
|
|
933
|
+
layout_ctx.reset(token)
|
|
934
|
+
else:
|
|
935
|
+
self._register_component(cid, nav_builder, action=nav_action)
|
|
936
|
+
|
|
937
|
+
# Return the runner wrapper
|
|
938
|
+
current_key = current_page_key_state.value
|
|
939
|
+
|
|
940
|
+
class PageRunner:
|
|
941
|
+
def __init__(self, app, page_state, pages_map, reactivity_mode):
|
|
942
|
+
self.app = app
|
|
943
|
+
self.state = page_state
|
|
944
|
+
self.pages_map = pages_map
|
|
945
|
+
self.reactivity_mode = reactivity_mode
|
|
946
|
+
|
|
947
|
+
def run(self):
|
|
948
|
+
# Progressive Mode: Register page renderer as a regular component
|
|
949
|
+
# Navigation state changes trigger page re-render.
|
|
950
|
+
# If reactivity_mode=True, we subscribe to state changes inside page function too.
|
|
951
|
+
cid = self.app._get_next_cid("page_renderer")
|
|
952
|
+
|
|
953
|
+
def page_builder():
|
|
954
|
+
# Set context ONLY for reading navigation state
|
|
955
|
+
token = rendering_ctx.set(cid)
|
|
956
|
+
|
|
957
|
+
# Store Current Page Renderer CID for Reactivity Blocks
|
|
958
|
+
p_token = page_ctx.set(cid)
|
|
959
|
+
|
|
960
|
+
try:
|
|
961
|
+
key = self.state.value # Subscribe to navigation state
|
|
962
|
+
|
|
963
|
+
# Execute the current page function
|
|
964
|
+
p = self.pages_map.get(key)
|
|
965
|
+
if p:
|
|
966
|
+
# Collect components from the page
|
|
967
|
+
store = get_session_store()
|
|
968
|
+
# Clear previous dynamic order for this page render
|
|
969
|
+
previous_order = store['order'].copy()
|
|
970
|
+
previous_fragments = {k: v.copy() for k, v in store['fragment_components'].items()}
|
|
971
|
+
store['order'] = []
|
|
972
|
+
store['fragment_components'] = {} # Clear fragments to prevent duplicates
|
|
973
|
+
|
|
974
|
+
try:
|
|
975
|
+
# Start executing page function
|
|
976
|
+
# If reactivity_mode is False, reset context (default, non-reactive page script)
|
|
977
|
+
# If reactivity_mode is True, KEEP context (page script registers dependencies on page_renderer)
|
|
978
|
+
if not self.reactivity_mode:
|
|
979
|
+
rendering_ctx.reset(token)
|
|
980
|
+
|
|
981
|
+
p.entry_point()
|
|
982
|
+
|
|
983
|
+
# Re-enable rendering_ctx if it was reset
|
|
984
|
+
if not self.reactivity_mode:
|
|
985
|
+
token = rendering_ctx.set(cid)
|
|
986
|
+
|
|
987
|
+
htmls = []
|
|
988
|
+
for page_cid in store['order']:
|
|
989
|
+
builder = store['builders'].get(page_cid) or self.app.static_builders.get(page_cid)
|
|
990
|
+
if builder:
|
|
991
|
+
htmls.append(builder().render())
|
|
992
|
+
|
|
993
|
+
content = '\n'.join(htmls)
|
|
994
|
+
return Component("div", id=cid, content=content, class_="page-container")
|
|
995
|
+
finally:
|
|
996
|
+
# Restore previous state (always, even on exception)
|
|
997
|
+
store['order'] = previous_order
|
|
998
|
+
store['fragment_components'] = previous_fragments
|
|
999
|
+
|
|
1000
|
+
return Component("div", id=cid, content="", class_="page-container")
|
|
1001
|
+
finally:
|
|
1002
|
+
# Ensure context is reset
|
|
1003
|
+
if rendering_ctx.get() == cid:
|
|
1004
|
+
rendering_ctx.reset(token)
|
|
1005
|
+
page_ctx.reset(p_token)
|
|
1006
|
+
|
|
1007
|
+
# Register the page renderer as a regular component
|
|
1008
|
+
self.app._register_component(cid, page_builder)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
page_runner = PageRunner(self, current_page_key_state, {p.key: p for p in final_pages}, reactivity_mode)
|
|
1012
|
+
|
|
1013
|
+
# Auto-run if enabled
|
|
1014
|
+
if auto_run:
|
|
1015
|
+
page_runner.run()
|
|
1016
|
+
|
|
1017
|
+
return page_runner
|
|
1018
|
+
|
|
1019
|
+
# --- Routes ---
|
|
1020
|
+
def _setup_routes(self):
|
|
1021
|
+
"""Setup FastAPI routes"""
|
|
1022
|
+
@self.fastapi.middleware("http")
|
|
1023
|
+
async def mw(request: Request, call_next):
|
|
1024
|
+
# Native mode security: Block web browser access
|
|
1025
|
+
if self.native_token is not None:
|
|
1026
|
+
token_from_request = request.query_params.get("_native_token")
|
|
1027
|
+
token_from_cookie = request.cookies.get("_native_token")
|
|
1028
|
+
user_agent = request.headers.get("user-agent", "")
|
|
1029
|
+
|
|
1030
|
+
# Debug logging for security check
|
|
1031
|
+
self.debug_print(f"[NATIVE SECURITY CHECK]")
|
|
1032
|
+
self.debug_print(f" Token from request: {token_from_request[:20] if token_from_request else None}...")
|
|
1033
|
+
self.debug_print(f" Token from cookie: {token_from_cookie[:20] if token_from_cookie else None}...")
|
|
1034
|
+
self.debug_print(f" Expected token: {self.native_token[:20]}...")
|
|
1035
|
+
self.debug_print(f" User-Agent: {user_agent}")
|
|
1036
|
+
|
|
1037
|
+
# Verify token
|
|
1038
|
+
is_valid_token = (token_from_request == self.native_token or token_from_cookie == self.native_token)
|
|
1039
|
+
|
|
1040
|
+
# Block if token is invalid
|
|
1041
|
+
if not is_valid_token:
|
|
1042
|
+
from fastapi.responses import HTMLResponse
|
|
1043
|
+
self.debug_print(f" [X] ACCESS DENIED - Invalid or missing token")
|
|
1044
|
+
return HTMLResponse(
|
|
1045
|
+
content="""
|
|
1046
|
+
<html>
|
|
1047
|
+
<head><title>Access Denied</title></head>
|
|
1048
|
+
<body style="font-family: system-ui; padding: 2rem; text-align: center;">
|
|
1049
|
+
<h1>[LOCK] Access Denied</h1>
|
|
1050
|
+
<p>This application is running in <strong>native desktop mode</strong>.</p>
|
|
1051
|
+
<p>Web browser access is disabled for security reasons.</p>
|
|
1052
|
+
<hr style="margin: 2rem auto; width: 50%;">
|
|
1053
|
+
<small>If you are the owner, please use the desktop application.</small>
|
|
1054
|
+
</body>
|
|
1055
|
+
</html>
|
|
1056
|
+
""",
|
|
1057
|
+
status_code=403
|
|
1058
|
+
)
|
|
1059
|
+
else:
|
|
1060
|
+
self.debug_print(f" [OK] ACCESS GRANTED - Valid token")
|
|
1061
|
+
|
|
1062
|
+
# Session ID: get from cookie (all tabs share same session)
|
|
1063
|
+
sid = request.cookies.get("ss_sid") or str(uuid.uuid4())
|
|
1064
|
+
|
|
1065
|
+
t = session_ctx.set(sid)
|
|
1066
|
+
response = await call_next(request)
|
|
1067
|
+
session_ctx.reset(t)
|
|
1068
|
+
|
|
1069
|
+
# Set cookie
|
|
1070
|
+
is_https = request.url.scheme == "https"
|
|
1071
|
+
response.set_cookie(
|
|
1072
|
+
"ss_sid",
|
|
1073
|
+
sid,
|
|
1074
|
+
httponly=True,
|
|
1075
|
+
secure=is_https,
|
|
1076
|
+
samesite="lax"
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
# Set native token cookie
|
|
1080
|
+
if self.native_token and not request.cookies.get("_native_token"):
|
|
1081
|
+
response.set_cookie(
|
|
1082
|
+
"_native_token",
|
|
1083
|
+
self.native_token,
|
|
1084
|
+
httponly=True,
|
|
1085
|
+
secure=is_https,
|
|
1086
|
+
samesite="strict"
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
return response
|
|
1090
|
+
|
|
1091
|
+
@self.fastapi.get("/")
|
|
1092
|
+
async def index(request: Request):
|
|
1093
|
+
# Note: _theme_state, _selection_state, _animation_state and their updaters
|
|
1094
|
+
# are already initialized in __init__, no need to re-initialize here
|
|
1095
|
+
|
|
1096
|
+
main_c, sidebar_c = self._render_all()
|
|
1097
|
+
store = get_session_store()
|
|
1098
|
+
t = store['theme']
|
|
1099
|
+
|
|
1100
|
+
sidebar_style = "" if (sidebar_c or self.static_sidebar_order) else "display: none;"
|
|
1101
|
+
main_class = "" if (sidebar_c or self.static_sidebar_order) else "sidebar-collapsed"
|
|
1102
|
+
|
|
1103
|
+
# Generate CSRF token
|
|
1104
|
+
# Get sid from context (set by middleware) instead of cookies (not set yet on first visit)
|
|
1105
|
+
try:
|
|
1106
|
+
sid = session_ctx.get()
|
|
1107
|
+
except LookupError:
|
|
1108
|
+
sid = request.cookies.get("ss_sid")
|
|
1109
|
+
|
|
1110
|
+
csrf_token = self._generate_csrf_token(sid) if sid and self.csrf_enabled else ""
|
|
1111
|
+
csrf_script = f'<script>window._csrf_token = "{csrf_token}";</script>' if csrf_token else ""
|
|
1112
|
+
|
|
1113
|
+
if self.debug_mode:
|
|
1114
|
+
print(f"[DEBUG] Session ID: {sid[:8] if sid else 'None'}...")
|
|
1115
|
+
print(f"[DEBUG] CSRF enabled: {self.csrf_enabled}")
|
|
1116
|
+
print(f"[DEBUG] CSRF token generated: {bool(csrf_token)}")
|
|
1117
|
+
|
|
1118
|
+
# Debug flag injection
|
|
1119
|
+
debug_script = f'<script>window._debug_mode = {str(self.debug_mode).lower()};</script>'
|
|
1120
|
+
|
|
1121
|
+
html = HTML_TEMPLATE.replace("%CONTENT%", main_c).replace("%SIDEBAR_CONTENT%", sidebar_c).replace("%SIDEBAR_STYLE%", sidebar_style).replace("%MAIN_CLASS%", main_class).replace("%MODE%", self.mode).replace("%TITLE%", self.app_title).replace("%THEME_CLASS%", t.theme_class).replace("%CSS_VARS%", t.to_css_vars()).replace("%SPLASH%", self._splash_html if self.show_splash else "").replace("%CONTAINER_MAX_WIDTH%", self.container_max_width).replace("%CSRF_SCRIPT%", csrf_script).replace("%DEBUG_SCRIPT%", debug_script)
|
|
1122
|
+
return HTMLResponse(html)
|
|
1123
|
+
|
|
1124
|
+
@self.fastapi.post("/action/{cid}")
|
|
1125
|
+
async def action(request: Request, cid: str):
|
|
1126
|
+
# Session ID: get from cookie
|
|
1127
|
+
sid = request.cookies.get("ss_sid")
|
|
1128
|
+
|
|
1129
|
+
# CSRF verification
|
|
1130
|
+
if self.csrf_enabled:
|
|
1131
|
+
f = await request.form()
|
|
1132
|
+
csrf_token = f.get("_csrf_token") or request.headers.get("X-CSRF-Token")
|
|
1133
|
+
|
|
1134
|
+
if not csrf_token or not self._verify_csrf_token(sid, csrf_token):
|
|
1135
|
+
from fastapi.responses import JSONResponse
|
|
1136
|
+
return JSONResponse(
|
|
1137
|
+
{"error": "Invalid CSRF token"},
|
|
1138
|
+
status_code=403
|
|
1139
|
+
)
|
|
1140
|
+
else:
|
|
1141
|
+
f = await request.form()
|
|
1142
|
+
|
|
1143
|
+
v = f.get("value")
|
|
1144
|
+
store = get_session_store()
|
|
1145
|
+
act = store['actions'].get(cid) or self.static_actions.get(cid)
|
|
1146
|
+
if act:
|
|
1147
|
+
if not callable(act):
|
|
1148
|
+
# Debug: print what we got instead
|
|
1149
|
+
self.debug_print(f"ERROR: Action for {cid} is not callable. Got: {type(act)} = {repr(act)}")
|
|
1150
|
+
return HTMLResponse("")
|
|
1151
|
+
|
|
1152
|
+
store['eval_queue'] = []
|
|
1153
|
+
act(v) if v is not None else act()
|
|
1154
|
+
|
|
1155
|
+
dirty = self._get_dirty_rendered()
|
|
1156
|
+
|
|
1157
|
+
# Separate clicked component from other updates
|
|
1158
|
+
clicked_component = None
|
|
1159
|
+
other_dirty = []
|
|
1160
|
+
for c in dirty:
|
|
1161
|
+
if c.id == cid:
|
|
1162
|
+
clicked_component = c
|
|
1163
|
+
else:
|
|
1164
|
+
other_dirty.append(c)
|
|
1165
|
+
|
|
1166
|
+
# Re-render clicked component if not dirty
|
|
1167
|
+
if clicked_component is None:
|
|
1168
|
+
builder = store['builders'].get(cid) or self.static_builders.get(cid)
|
|
1169
|
+
if builder:
|
|
1170
|
+
clicked_component = builder()
|
|
1171
|
+
|
|
1172
|
+
# Build response: clicked component HTML + OOB for others
|
|
1173
|
+
response_html = clicked_component.render() if clicked_component else ""
|
|
1174
|
+
response_html += self.lite_engine.wrap_oob(other_dirty)
|
|
1175
|
+
|
|
1176
|
+
# Process Toasts
|
|
1177
|
+
toasts = store.get('toasts', [])
|
|
1178
|
+
if toasts:
|
|
1179
|
+
import html as html_lib
|
|
1180
|
+
toasts_json = json.dumps(toasts)
|
|
1181
|
+
toasts_escaped = html_lib.escape(toasts_json)
|
|
1182
|
+
|
|
1183
|
+
toast_injector = f'''<div id="toast-injector" hx-swap-oob="true" data-toasts="{toasts_escaped}">
|
|
1184
|
+
<script>
|
|
1185
|
+
(function() {{
|
|
1186
|
+
var container = document.getElementById('toast-injector');
|
|
1187
|
+
if (!container) return;
|
|
1188
|
+
var toastsAttr = container.getAttribute('data-toasts');
|
|
1189
|
+
if (!toastsAttr) return;
|
|
1190
|
+
var toasts = JSON.parse(toastsAttr);
|
|
1191
|
+
toasts.forEach(function(t) {{
|
|
1192
|
+
if (typeof createToast === 'function') {{
|
|
1193
|
+
createToast(t.message, t.variant, t.icon);
|
|
1194
|
+
}}
|
|
1195
|
+
}});
|
|
1196
|
+
container.removeAttribute('data-toasts');
|
|
1197
|
+
}})();
|
|
1198
|
+
</script>
|
|
1199
|
+
</div>'''
|
|
1200
|
+
response_html += toast_injector
|
|
1201
|
+
store['toasts'] = []
|
|
1202
|
+
|
|
1203
|
+
# Process Effects (Balloons, Snow)
|
|
1204
|
+
effects = store.get('effects', [])
|
|
1205
|
+
if effects:
|
|
1206
|
+
effects_json = json.dumps(effects)
|
|
1207
|
+
effect_injector = f'''<div id="effects-injector" hx-swap-oob="true" data-effects='{effects_json}'>
|
|
1208
|
+
<script>
|
|
1209
|
+
(function() {{
|
|
1210
|
+
const container = document.getElementById('effects-injector');
|
|
1211
|
+
if (!container) return;
|
|
1212
|
+
const effects = JSON.parse(container.getAttribute('data-effects'));
|
|
1213
|
+
effects.forEach(e => {{
|
|
1214
|
+
if (e === 'balloons') createBalloons();
|
|
1215
|
+
if (e === 'snow') createSnow();
|
|
1216
|
+
}});
|
|
1217
|
+
container.removeAttribute('data-effects');
|
|
1218
|
+
}})();
|
|
1219
|
+
</script>
|
|
1220
|
+
</div>'''
|
|
1221
|
+
response_html += effect_injector
|
|
1222
|
+
store['effects'] = []
|
|
1223
|
+
|
|
1224
|
+
return HTMLResponse(response_html)
|
|
1225
|
+
return HTMLResponse("")
|
|
1226
|
+
|
|
1227
|
+
@self.fastapi.websocket("/ws")
|
|
1228
|
+
async def ws(ws: WebSocket):
|
|
1229
|
+
await ws.accept()
|
|
1230
|
+
|
|
1231
|
+
# Session ID: get from cookie (all tabs share same session)
|
|
1232
|
+
sid = ws.cookies.get("ss_sid") or str(uuid.uuid4())
|
|
1233
|
+
|
|
1234
|
+
self.debug_print(f"[WEBSOCKET] Session: {sid[:8]}...")
|
|
1235
|
+
|
|
1236
|
+
# Set session context (outside while loop - very important!)
|
|
1237
|
+
t = session_ctx.set(sid)
|
|
1238
|
+
self.ws_engine.sockets[sid] = ws
|
|
1239
|
+
|
|
1240
|
+
# Message processing function
|
|
1241
|
+
async def process_message(data):
|
|
1242
|
+
if data.get('type') != 'click':
|
|
1243
|
+
return
|
|
1244
|
+
|
|
1245
|
+
# Debug WebSocket data
|
|
1246
|
+
self.debug_print(f"[WEBSOCKET ACTION] CID: {data.get('id')}")
|
|
1247
|
+
self.debug_print(f" Native mode: {self.native_token is not None}")
|
|
1248
|
+
self.debug_print(f" CSRF enabled: {self.csrf_enabled}")
|
|
1249
|
+
self.debug_print(f" Native token in payload: {data.get('_native_token')[:20] if data.get('_native_token') else None}...")
|
|
1250
|
+
|
|
1251
|
+
# Native mode verification (high priority)
|
|
1252
|
+
if self.native_token is not None:
|
|
1253
|
+
native_token = data.get('_native_token')
|
|
1254
|
+
if native_token != self.native_token:
|
|
1255
|
+
self.debug_print(f" [X] Native token mismatch!")
|
|
1256
|
+
await ws.send_json({"type": "error", "message": "Invalid native token"})
|
|
1257
|
+
return
|
|
1258
|
+
else:
|
|
1259
|
+
self.debug_print(f" [OK] Native token valid - Skipping CSRF check")
|
|
1260
|
+
else:
|
|
1261
|
+
# CSRF verification for WebSocket (non-native only)
|
|
1262
|
+
if self.csrf_enabled:
|
|
1263
|
+
csrf_token = data.get('_csrf_token')
|
|
1264
|
+
if not csrf_token or not self._verify_csrf_token(sid, csrf_token):
|
|
1265
|
+
self.debug_print(f" [X] CSRF token invalid")
|
|
1266
|
+
await ws.send_json({"type": "error", "message": "Invalid CSRF token"})
|
|
1267
|
+
return
|
|
1268
|
+
else:
|
|
1269
|
+
self.debug_print(f" [OK] CSRF token valid")
|
|
1270
|
+
|
|
1271
|
+
cid, v = data.get('id'), data.get('value')
|
|
1272
|
+
store = get_session_store()
|
|
1273
|
+
act = store['actions'].get(cid) or self.static_actions.get(cid)
|
|
1274
|
+
|
|
1275
|
+
self.debug_print(f" Action found: {act is not None}")
|
|
1276
|
+
|
|
1277
|
+
# Detect if this is a navigation action (nav menu click)
|
|
1278
|
+
is_navigation = cid.startswith('nav_menu')
|
|
1279
|
+
|
|
1280
|
+
if act:
|
|
1281
|
+
store['eval_queue'] = []
|
|
1282
|
+
self.debug_print(f" Executing action for CID: {cid} (navigation={is_navigation})...")
|
|
1283
|
+
act(v) if v is not None else act()
|
|
1284
|
+
self.debug_print(f" Action executed")
|
|
1285
|
+
|
|
1286
|
+
for code in store.get('eval_queue', []):
|
|
1287
|
+
await self.ws_engine.push_eval(sid, code)
|
|
1288
|
+
store['eval_queue'] = []
|
|
1289
|
+
|
|
1290
|
+
dirty = self._get_dirty_rendered()
|
|
1291
|
+
self.debug_print(f" Dirty components: {len(dirty)} ({[c.id for c in dirty]})")
|
|
1292
|
+
|
|
1293
|
+
# Send all dirty components via WebSocket
|
|
1294
|
+
# Pass is_navigation flag to enable/disable smooth transitions
|
|
1295
|
+
if dirty:
|
|
1296
|
+
self.debug_print(f" Sending {len(dirty)} updates via WebSocket (navigation={is_navigation})...")
|
|
1297
|
+
await self.ws_engine.push_updates(sid, dirty, is_navigation=is_navigation)
|
|
1298
|
+
self.debug_print(f" [OK] Updates sent successfully")
|
|
1299
|
+
else:
|
|
1300
|
+
self.debug_print(f" [!] No dirty components found - nothing to update")
|
|
1301
|
+
|
|
1302
|
+
try:
|
|
1303
|
+
# Message processing loop
|
|
1304
|
+
while True:
|
|
1305
|
+
data = await ws.receive_json()
|
|
1306
|
+
await process_message(data)
|
|
1307
|
+
except WebSocketDisconnect:
|
|
1308
|
+
if sid and sid in self.ws_engine.sockets:
|
|
1309
|
+
del self.ws_engine.sockets[sid]
|
|
1310
|
+
self.debug_print(f"[WEBSOCKET] Disconnected: {sid[:8]}...")
|
|
1311
|
+
finally:
|
|
1312
|
+
if t is not None:
|
|
1313
|
+
session_ctx.reset(t)
|
|
1314
|
+
|
|
1315
|
+
def _run_web_reload(self, args):
|
|
1316
|
+
"""Run with hot reload in web mode (process restart)"""
|
|
1317
|
+
self.debug_print(f"[HOT RELOAD] Watching {os.getcwd()}...")
|
|
1318
|
+
|
|
1319
|
+
iteration = 0
|
|
1320
|
+
while True:
|
|
1321
|
+
iteration += 1
|
|
1322
|
+
# Prepare environment
|
|
1323
|
+
env = os.environ.copy()
|
|
1324
|
+
env["VIOLIT_WORKER"] = "1"
|
|
1325
|
+
|
|
1326
|
+
# Start worker
|
|
1327
|
+
self.debug_print(f"\n[Web Reload] Starting server (iteration {iteration})...", flush=True)
|
|
1328
|
+
p = subprocess.Popen([sys.executable] + sys.argv, env=env)
|
|
1329
|
+
|
|
1330
|
+
# Watch for changes
|
|
1331
|
+
watcher = FileWatcher(debug_mode=self.debug_mode)
|
|
1332
|
+
intentional_restart = False
|
|
1333
|
+
|
|
1334
|
+
try:
|
|
1335
|
+
while p.poll() is None:
|
|
1336
|
+
if watcher.check():
|
|
1337
|
+
self.debug_print("\n[Web Reload] 🔄 Reloading server...", flush=True)
|
|
1338
|
+
intentional_restart = True
|
|
1339
|
+
p.terminate()
|
|
1340
|
+
try:
|
|
1341
|
+
p.wait(timeout=2)
|
|
1342
|
+
self.debug_print("[Web Reload] ✓ Server stopped gracefully", flush=True)
|
|
1343
|
+
except subprocess.TimeoutExpired:
|
|
1344
|
+
self.debug_print("[Web Reload] WARNING: Force killing server...", flush=True)
|
|
1345
|
+
p.kill()
|
|
1346
|
+
p.wait()
|
|
1347
|
+
break
|
|
1348
|
+
time.sleep(0.5)
|
|
1349
|
+
except KeyboardInterrupt:
|
|
1350
|
+
p.terminate()
|
|
1351
|
+
sys.exit(0)
|
|
1352
|
+
|
|
1353
|
+
# If it was an intentional restart, wait a bit so browser can detect server is down
|
|
1354
|
+
if intentional_restart:
|
|
1355
|
+
time.sleep(1.5) # Give browser time to detect server is down (increased for reliability)
|
|
1356
|
+
continue
|
|
1357
|
+
|
|
1358
|
+
# If process exited unexpectedly (crashed), wait for file change
|
|
1359
|
+
if p.returncode is not None:
|
|
1360
|
+
self.debug_print("[Web Reload] WARNING: Server exited unexpectedly. Waiting for file changes...", flush=True)
|
|
1361
|
+
while not watcher.check():
|
|
1362
|
+
time.sleep(0.5)
|
|
1363
|
+
self.debug_print("[Web Reload] Reloading after crash...", flush=True)
|
|
1364
|
+
|
|
1365
|
+
def _run_native_reload(self, args):
|
|
1366
|
+
"""Run with hot reload in desktop mode"""
|
|
1367
|
+
# Generate security token for native mode
|
|
1368
|
+
self.native_token = secrets.token_urlsafe(32)
|
|
1369
|
+
self.is_native_mode = True
|
|
1370
|
+
|
|
1371
|
+
self.debug_print(f"[HOT RELOAD] Desktop mode - Watching {os.getcwd()}...")
|
|
1372
|
+
|
|
1373
|
+
# Shared state for the server process
|
|
1374
|
+
server_process = [None]
|
|
1375
|
+
should_exit = [False]
|
|
1376
|
+
|
|
1377
|
+
def server_manager():
|
|
1378
|
+
iteration = 0
|
|
1379
|
+
while not should_exit[0]:
|
|
1380
|
+
iteration += 1
|
|
1381
|
+
env = os.environ.copy()
|
|
1382
|
+
env["VIOLIT_WORKER"] = "1"
|
|
1383
|
+
env["VIOLIT_SERVER_ONLY"] = "1"
|
|
1384
|
+
env["VIOLIT_NATIVE_TOKEN"] = self.native_token
|
|
1385
|
+
env["VIOLIT_NATIVE_MODE"] = "1"
|
|
1386
|
+
|
|
1387
|
+
# Start server
|
|
1388
|
+
self.debug_print(f"\n[Server Manager] Starting server (iteration {iteration})...", flush=True)
|
|
1389
|
+
server_process[0] = subprocess.Popen(
|
|
1390
|
+
[sys.executable] + sys.argv,
|
|
1391
|
+
env=env,
|
|
1392
|
+
stdout=subprocess.PIPE if iteration > 1 else None,
|
|
1393
|
+
stderr=subprocess.STDOUT if iteration > 1 else None
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
# Give server time to start
|
|
1397
|
+
time.sleep(0.3)
|
|
1398
|
+
|
|
1399
|
+
watcher = FileWatcher(debug_mode=self.debug_mode)
|
|
1400
|
+
|
|
1401
|
+
# Watch loop
|
|
1402
|
+
intentional_restart = False
|
|
1403
|
+
while server_process[0].poll() is None and not should_exit[0]:
|
|
1404
|
+
if watcher.check():
|
|
1405
|
+
self.debug_print("\n[Server Manager] 🔄 Reloading server...", flush=True)
|
|
1406
|
+
intentional_restart = True
|
|
1407
|
+
server_process[0].terminate()
|
|
1408
|
+
try:
|
|
1409
|
+
server_process[0].wait(timeout=2)
|
|
1410
|
+
self.debug_print("[Server Manager] ✓ Server stopped gracefully", flush=True)
|
|
1411
|
+
except subprocess.TimeoutExpired:
|
|
1412
|
+
self.debug_print("[Server Manager] WARNING: Force killing server...", flush=True)
|
|
1413
|
+
server_process[0].kill()
|
|
1414
|
+
server_process[0].wait()
|
|
1415
|
+
break
|
|
1416
|
+
time.sleep(0.5)
|
|
1417
|
+
|
|
1418
|
+
# If it was an intentional restart, reload webview and continue
|
|
1419
|
+
if intentional_restart:
|
|
1420
|
+
# Wait for server to be ready
|
|
1421
|
+
time.sleep(0.5)
|
|
1422
|
+
# Reload webview
|
|
1423
|
+
try:
|
|
1424
|
+
if webview.windows:
|
|
1425
|
+
webview.windows[0].load_url(f"http://127.0.0.1:{args.port}?_native_token={self.native_token}")
|
|
1426
|
+
self.debug_print("[Server Manager] \u2713 Webview reloaded", flush=True)
|
|
1427
|
+
except Exception as e:
|
|
1428
|
+
self.debug_print(f"[Server Manager] \u26a0 Webview reload failed: {e}", flush=True)
|
|
1429
|
+
continue
|
|
1430
|
+
|
|
1431
|
+
# If exited unexpectedly (crashed), wait for file change
|
|
1432
|
+
if server_process[0].poll() is not None and not should_exit[0]:
|
|
1433
|
+
self.debug_print("[Server Manager] WARNING: Server exited unexpectedly. Waiting for file changes...", flush=True)
|
|
1434
|
+
while not watcher.check() and not should_exit[0]:
|
|
1435
|
+
time.sleep(0.5)
|
|
1436
|
+
|
|
1437
|
+
# Start server manager thread
|
|
1438
|
+
t = threading.Thread(target=server_manager, daemon=True)
|
|
1439
|
+
t.start()
|
|
1440
|
+
|
|
1441
|
+
# Patch webview to use custom icon (Windows)
|
|
1442
|
+
self._patch_webview_icon()
|
|
1443
|
+
|
|
1444
|
+
# Start WebView (Main Thread)
|
|
1445
|
+
win_args = {
|
|
1446
|
+
'text_select': True,
|
|
1447
|
+
'width': self.width,
|
|
1448
|
+
'height': self.height,
|
|
1449
|
+
'on_top': self.on_top
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
# Pass icon to start (for non-WinForms backends)
|
|
1453
|
+
start_args = {}
|
|
1454
|
+
sig_start = inspect.signature(webview.start)
|
|
1455
|
+
if 'icon' in sig_start.parameters and self.app_icon:
|
|
1456
|
+
start_args['icon'] = self.app_icon
|
|
1457
|
+
|
|
1458
|
+
webview.create_window(self.app_title, f"http://127.0.0.1:{args.port}?_native_token={self.native_token}", **win_args)
|
|
1459
|
+
webview.start(**start_args)
|
|
1460
|
+
|
|
1461
|
+
# Cleanup
|
|
1462
|
+
should_exit[0] = True
|
|
1463
|
+
if server_process[0]:
|
|
1464
|
+
try:
|
|
1465
|
+
server_process[0].terminate()
|
|
1466
|
+
except:
|
|
1467
|
+
pass
|
|
1468
|
+
sys.exit(0)
|
|
1469
|
+
|
|
1470
|
+
# Broadcasting Methods (WebSocket-based real-time sync)
|
|
1471
|
+
def broadcast_eval(self, js_code: str, exclude_current: bool = False):
|
|
1472
|
+
self.broadcaster.eval_all(js_code, exclude_current=exclude_current)
|
|
1473
|
+
|
|
1474
|
+
def broadcast_reload(self, exclude_current: bool = False):
|
|
1475
|
+
self.broadcaster.reload_all(exclude_current=exclude_current)
|
|
1476
|
+
|
|
1477
|
+
def broadcast_dom_update(self, container_id: str, html: str,
|
|
1478
|
+
position: str = 'prepend', animate: bool = True,
|
|
1479
|
+
exclude_current: bool = False):
|
|
1480
|
+
self.broadcaster.broadcast_dom_update(
|
|
1481
|
+
container_id, html, position, animate, exclude_current
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
def broadcast_event(self, event_name: str, data: dict,
|
|
1485
|
+
exclude_current: bool = False):
|
|
1486
|
+
self.broadcaster.broadcast_event(event_name, data, exclude_current)
|
|
1487
|
+
|
|
1488
|
+
def broadcast_dom_remove(self, selector: str = None,
|
|
1489
|
+
element_id: str = None,
|
|
1490
|
+
data_attribute: tuple = None,
|
|
1491
|
+
animate: bool = True,
|
|
1492
|
+
animation_type: str = 'fade-right',
|
|
1493
|
+
duration: int = 500,
|
|
1494
|
+
exclude_current: bool = False):
|
|
1495
|
+
"""Remove DOM element from all clients with animation
|
|
1496
|
+
|
|
1497
|
+
Example:
|
|
1498
|
+
# Remove by ID
|
|
1499
|
+
app.broadcast_dom_remove(element_id='my-element')
|
|
1500
|
+
|
|
1501
|
+
# Remove by CSS selector
|
|
1502
|
+
app.broadcast_dom_remove(selector='.old-posts')
|
|
1503
|
+
"""
|
|
1504
|
+
self.broadcaster.broadcast_dom_remove(
|
|
1505
|
+
selector=selector,
|
|
1506
|
+
element_id=element_id,
|
|
1507
|
+
data_attribute=data_attribute,
|
|
1508
|
+
animate=animate,
|
|
1509
|
+
animation_type=animation_type,
|
|
1510
|
+
duration=duration,
|
|
1511
|
+
exclude_current=exclude_current
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
def run(self):
|
|
1515
|
+
"""Run the application"""
|
|
1516
|
+
p = argparse.ArgumentParser()
|
|
1517
|
+
p.add_argument("--native", action="store_true")
|
|
1518
|
+
p.add_argument("--nosplash", action="store_true", help="Disable splash screen")
|
|
1519
|
+
p.add_argument("--reload", action="store_true", help="Enable hot reload")
|
|
1520
|
+
p.add_argument("--lite", action="store_true", help="Use Lite mode (HTMX)")
|
|
1521
|
+
p.add_argument("--debug", action="store_true", help="Enable developer tools (native mode)")
|
|
1522
|
+
p.add_argument("--port", type=int, default=8000)
|
|
1523
|
+
args, _ = p.parse_known_args()
|
|
1524
|
+
|
|
1525
|
+
# [Logging Filter] Reduce noise by filtering out polling requests
|
|
1526
|
+
try:
|
|
1527
|
+
import logging
|
|
1528
|
+
class PollingFilter(logging.Filter):
|
|
1529
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
1530
|
+
return "/?_t=" not in record.getMessage()
|
|
1531
|
+
|
|
1532
|
+
# Apply filter to uvicorn.access logger
|
|
1533
|
+
logging.getLogger("uvicorn.access").addFilter(PollingFilter())
|
|
1534
|
+
except Exception:
|
|
1535
|
+
pass # Ignore if logging setup fails
|
|
1536
|
+
|
|
1537
|
+
if args.lite:
|
|
1538
|
+
self.mode = "lite"
|
|
1539
|
+
# Also create lite engine if not already created
|
|
1540
|
+
if self.lite_engine is None:
|
|
1541
|
+
from .engine import LiteEngine
|
|
1542
|
+
self.lite_engine = LiteEngine()
|
|
1543
|
+
|
|
1544
|
+
# Handle internal env var to force "Server Only" mode (for native reload)
|
|
1545
|
+
if os.environ.get("VIOLIT_SERVER_ONLY"):
|
|
1546
|
+
args.native = False
|
|
1547
|
+
|
|
1548
|
+
# Hot Reload Manager Logic
|
|
1549
|
+
if args.reload and not os.environ.get("VIOLIT_WORKER"):
|
|
1550
|
+
if args.native:
|
|
1551
|
+
self._run_native_reload(args)
|
|
1552
|
+
else:
|
|
1553
|
+
self._run_web_reload(args)
|
|
1554
|
+
return
|
|
1555
|
+
|
|
1556
|
+
self.show_splash = not args.nosplash
|
|
1557
|
+
if self.show_splash:
|
|
1558
|
+
self._splash_html = """
|
|
1559
|
+
<div id="splash" style="position:fixed;top:0;left:0;width:100%;height:100%;background:var(--sl-bg);z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;transition:opacity 0.4s ease;">
|
|
1560
|
+
<sl-spinner style="font-size: 3rem; --indicator-color: var(--sl-primary); margin-bottom: 1rem;"></sl-spinner>
|
|
1561
|
+
<div style="font-size:1.5rem;font-weight:600;color:var(--sl-text);" class="gradient-text">Loading...</div>
|
|
1562
|
+
</div>
|
|
1563
|
+
<script>
|
|
1564
|
+
window.addEventListener('load', ()=>{
|
|
1565
|
+
setTimeout(()=>{
|
|
1566
|
+
const s=document.getElementById('splash');
|
|
1567
|
+
if(s){
|
|
1568
|
+
s.style.opacity=0;
|
|
1569
|
+
setTimeout(()=>s.remove(), 400);
|
|
1570
|
+
}
|
|
1571
|
+
}, 800);
|
|
1572
|
+
});
|
|
1573
|
+
</script>
|
|
1574
|
+
"""
|
|
1575
|
+
|
|
1576
|
+
if args.native:
|
|
1577
|
+
# Generate security token for native mode
|
|
1578
|
+
self.native_token = secrets.token_urlsafe(32)
|
|
1579
|
+
self.is_native_mode = True
|
|
1580
|
+
|
|
1581
|
+
# Disable CSRF in native mode (local app security)
|
|
1582
|
+
self.csrf_enabled = False
|
|
1583
|
+
print("[SECURITY] CSRF protection disabled (native mode)")
|
|
1584
|
+
|
|
1585
|
+
# Use a shared flag to signal server shutdown
|
|
1586
|
+
server_shutdown = threading.Event()
|
|
1587
|
+
|
|
1588
|
+
def srv():
|
|
1589
|
+
# Run uvicorn in a way we can control or just let it die with daemon
|
|
1590
|
+
# Since we use daemon=True, it should die when main thread dies.
|
|
1591
|
+
# However, sometimes keeping the main thread alive for webview.start()
|
|
1592
|
+
# might cause issues if not cleaned up properly.
|
|
1593
|
+
# We'll stick to daemon=True but force exit after webview.start returns.
|
|
1594
|
+
uvicorn.run(self.fastapi, host="127.0.0.1", port=args.port, log_level="warning")
|
|
1595
|
+
|
|
1596
|
+
t = threading.Thread(target=srv, daemon=True)
|
|
1597
|
+
t.start()
|
|
1598
|
+
|
|
1599
|
+
time.sleep(1)
|
|
1600
|
+
|
|
1601
|
+
# Patch webview to use custom icon (Windows)
|
|
1602
|
+
self._patch_webview_icon()
|
|
1603
|
+
|
|
1604
|
+
# Start WebView - This blocks until window is closed
|
|
1605
|
+
win_args = {
|
|
1606
|
+
'text_select': True,
|
|
1607
|
+
'width': self.width,
|
|
1608
|
+
'height': self.height,
|
|
1609
|
+
'on_top': self.on_top
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
# Pass icon and debug mode to start (for non-WinForms backends)
|
|
1613
|
+
start_args = {}
|
|
1614
|
+
sig_start = inspect.signature(webview.start)
|
|
1615
|
+
|
|
1616
|
+
# Enable developer tools (when --debug flag is used)
|
|
1617
|
+
if args.debug:
|
|
1618
|
+
start_args['debug'] = True
|
|
1619
|
+
print("🔍 Debug mode enabled: Press F12 or Ctrl+Shift+I to open developer tools")
|
|
1620
|
+
|
|
1621
|
+
if 'icon' in sig_start.parameters and self.app_icon:
|
|
1622
|
+
start_args['icon'] = self.app_icon
|
|
1623
|
+
|
|
1624
|
+
# Add native token to URL for initial access
|
|
1625
|
+
webview.create_window(self.app_title, f"http://127.0.0.1:{args.port}?_native_token={self.native_token}", **win_args)
|
|
1626
|
+
webview.start(**start_args)
|
|
1627
|
+
|
|
1628
|
+
# Force exit after window closes to kill the uvicorn thread immediately
|
|
1629
|
+
print("App closed. Exiting...")
|
|
1630
|
+
os._exit(0)
|
|
1631
|
+
else:
|
|
1632
|
+
uvicorn.run(self.fastapi, host="0.0.0.0", port=args.port)
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
HTML_TEMPLATE = """
|
|
1636
|
+
<!DOCTYPE html>
|
|
1637
|
+
<html class="%THEME_CLASS%">
|
|
1638
|
+
<head>
|
|
1639
|
+
<meta charset="UTF-8">
|
|
1640
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1641
|
+
<meta name="htmx-config" content='{"defaultSwapDelay":0,"defaultSettleDelay":0}'>
|
|
1642
|
+
<title>%TITLE%</title>
|
|
1643
|
+
%CSRF_SCRIPT%
|
|
1644
|
+
%DEBUG_SCRIPT%
|
|
1645
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.12.0/cdn/themes/light.css" />
|
|
1646
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.12.0/cdn/themes/dark.css" />
|
|
1647
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.12.0/cdn/shoelace-autoloader.js"></script>
|
|
1648
|
+
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
1649
|
+
<script src="https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.0/dist/ag-grid-community.min.js"></script>
|
|
1650
|
+
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
|
1651
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap" rel="stylesheet">
|
|
1652
|
+
<style>
|
|
1653
|
+
:root {
|
|
1654
|
+
%CSS_VARS%
|
|
1655
|
+
--sidebar-width: 300px;
|
|
1656
|
+
}
|
|
1657
|
+
sl-alert { --sl-color-primary-500: var(--sl-primary); --sl-color-primary-600: var(--sl-primary); }
|
|
1658
|
+
sl-alert::part(base) { border: 1px solid var(--sl-border); }
|
|
1659
|
+
|
|
1660
|
+
sl-button {
|
|
1661
|
+
--sl-color-primary-500: var(--sl-primary);
|
|
1662
|
+
--sl-color-primary-600: color-mix(in srgb, var(--sl-primary), black 10%);
|
|
1663
|
+
}
|
|
1664
|
+
body { margin: 0; background: var(--sl-bg); color: var(--sl-text); font-family: 'Inter', sans-serif; min-height: 100vh; transition: background 0.3s, color 0.3s; }
|
|
1665
|
+
|
|
1666
|
+
/* Soft Animation Mode - Only for sidebar and page transitions */
|
|
1667
|
+
body.anim-soft #sidebar { transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s ease, opacity 0.3s ease; }
|
|
1668
|
+
body.anim-soft .page-container { animation: fade-in 0.3s ease-out; }
|
|
1669
|
+
|
|
1670
|
+
/* Hard Animation Mode */
|
|
1671
|
+
body.anim-hard *, body.anim-hard ::part(base) { transition: none !important; animation: none !important; }
|
|
1672
|
+
|
|
1673
|
+
#root { display: flex; width: 100%; min-height: 100vh; }
|
|
1674
|
+
#sidebar {
|
|
1675
|
+
position: fixed;
|
|
1676
|
+
top: 0;
|
|
1677
|
+
left: 0;
|
|
1678
|
+
width: var(--sidebar-width);
|
|
1679
|
+
height: 100vh;
|
|
1680
|
+
background: var(--sl-bg-card);
|
|
1681
|
+
border-right: 1px solid var(--sl-border);
|
|
1682
|
+
padding: 2rem 1rem;
|
|
1683
|
+
display: flex;
|
|
1684
|
+
flex-direction: column;
|
|
1685
|
+
gap: 1rem;
|
|
1686
|
+
overflow-y: auto;
|
|
1687
|
+
overflow-x: hidden;
|
|
1688
|
+
white-space: nowrap;
|
|
1689
|
+
z-index: 1100;
|
|
1690
|
+
}
|
|
1691
|
+
#sidebar.collapsed { width: 0; padding: 2rem 0; border-right: none; opacity: 0; }
|
|
1692
|
+
|
|
1693
|
+
#main {
|
|
1694
|
+
flex: 1;
|
|
1695
|
+
margin-left: var(--sidebar-width);
|
|
1696
|
+
display: flex;
|
|
1697
|
+
flex-direction: column;
|
|
1698
|
+
align-items: center;
|
|
1699
|
+
padding: 0 1.5rem 3rem 2.5rem;
|
|
1700
|
+
transition: margin-left 0.3s ease, padding 0.3s ease;
|
|
1701
|
+
}
|
|
1702
|
+
#main.sidebar-collapsed { margin-left: 0; }
|
|
1703
|
+
/* Chat input container positioning - respects sidebar */
|
|
1704
|
+
.chat-input-container { left: var(--sidebar-width) !important; transition: left 0.3s ease; }
|
|
1705
|
+
#sidebar.collapsed ~ #main .chat-input-container,
|
|
1706
|
+
#main.sidebar-collapsed .chat-input-container { left: 0 !important; }
|
|
1707
|
+
|
|
1708
|
+
#header { width: 100%; max-width: %CONTAINER_MAX_WIDTH%; padding: 1rem 0; display: flex; align-items: center; }
|
|
1709
|
+
#app { width: 100%; max-width: %CONTAINER_MAX_WIDTH%; display: flex; flex-direction: column; gap: 1.5rem; }
|
|
1710
|
+
|
|
1711
|
+
.fragment { display: flex; flex-direction: column; gap: 1.25rem; width: 100%; }
|
|
1712
|
+
.page-container { display: flex; flex-direction: column; gap: 1rem; width: 100%; }
|
|
1713
|
+
.card { background: var(--sl-bg-card); border: 1px solid var(--sl-border); padding: 1.5rem; border-radius: var(--sl-radius); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); margin-bottom: 0.5rem; }
|
|
1714
|
+
|
|
1715
|
+
/* Widget spacing - natural breathing room */
|
|
1716
|
+
.page-container > div { margin-bottom: 0.5rem; }
|
|
1717
|
+
|
|
1718
|
+
/* Headings need more space above to separate sections */
|
|
1719
|
+
h1, h2, h3 { font-weight: 600; margin: 0; }
|
|
1720
|
+
h1 { font-size: 2.25rem; line-height: 1.2; margin-bottom: 1rem; }
|
|
1721
|
+
h2 { font-size: 1.5rem; line-height: 1.3; margin-top: 1.5rem; margin-bottom: 0.75rem; }
|
|
1722
|
+
h3 { font-size: 1.25rem; line-height: 1.4; margin-top: 1.25rem; margin-bottom: 0.5rem; }
|
|
1723
|
+
.page-container > h1:first-child, .page-container > h2:first-child, .page-container > h3:first-child,
|
|
1724
|
+
h1:first-child, h2:first-child, h3:first-child { margin-top: 0; }
|
|
1725
|
+
|
|
1726
|
+
/* Shoelace component spacing */
|
|
1727
|
+
sl-input, sl-select, sl-textarea, sl-range, sl-checkbox, sl-switch, sl-radio-group, sl-color-picker {
|
|
1728
|
+
display: block;
|
|
1729
|
+
margin-bottom: 1rem;
|
|
1730
|
+
}
|
|
1731
|
+
sl-alert {
|
|
1732
|
+
display: block;
|
|
1733
|
+
margin-bottom: 1.25rem;
|
|
1734
|
+
}
|
|
1735
|
+
sl-button {
|
|
1736
|
+
margin-top: 0.25rem;
|
|
1737
|
+
margin-bottom: 0.5rem;
|
|
1738
|
+
}
|
|
1739
|
+
sl-divider, .divider {
|
|
1740
|
+
--color: var(--sl-border);
|
|
1741
|
+
margin: 1.5rem 0;
|
|
1742
|
+
width: 100%;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/* Column layouts */
|
|
1746
|
+
.columns { display: flex; gap: 1rem; width: 100%; margin-bottom: 0.5rem; }
|
|
1747
|
+
.column { flex: 1; display: flex; flex-direction: column; gap: 0.75rem; }
|
|
1748
|
+
|
|
1749
|
+
/* List container - predefined layout for reactive lists */
|
|
1750
|
+
.violit-list-container {
|
|
1751
|
+
display: flex;
|
|
1752
|
+
flex-direction: column;
|
|
1753
|
+
gap: 1rem;
|
|
1754
|
+
width: 100%;
|
|
1755
|
+
}
|
|
1756
|
+
.violit-list-container > * {
|
|
1757
|
+
width: 100%;
|
|
1758
|
+
}
|
|
1759
|
+
.violit-list-container sl-card {
|
|
1760
|
+
width: 100%;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
.gradient-text { background: linear-gradient(to right, var(--sl-primary), var(--sl-secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
|
1764
|
+
.text-muted { color: var(--sl-text-muted); }
|
|
1765
|
+
.metric-label { color: var(--sl-text-muted); font-size: 0.875rem; margin-bottom: 0.25rem; }
|
|
1766
|
+
.metric-value { font-size: 2rem; font-weight: 600; }
|
|
1767
|
+
.no-select { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; }
|
|
1768
|
+
|
|
1769
|
+
@keyframes fade-in {
|
|
1770
|
+
from { opacity: 0; transform: translateY(10px); filter: blur(4px); }
|
|
1771
|
+
to { opacity: 1; transform: translateY(0); filter: blur(0); }
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
/* Animations for Balloons and Snow */
|
|
1775
|
+
@keyframes float-up {
|
|
1776
|
+
0% { transform: translateY(var(--start-y, 100vh)) rotate(0deg); opacity: 1; }
|
|
1777
|
+
100% { transform: translateY(-20vh) rotate(360deg); opacity: 0; }
|
|
1778
|
+
}
|
|
1779
|
+
@keyframes fall-down {
|
|
1780
|
+
0% { transform: translateY(-10vh) rotate(0deg); opacity: 0; }
|
|
1781
|
+
10% { opacity: 1; }
|
|
1782
|
+
90% { opacity: 1; }
|
|
1783
|
+
100% { transform: translateY(110vh) rotate(360deg); opacity: 0; }
|
|
1784
|
+
}
|
|
1785
|
+
.balloon, .snowflake {
|
|
1786
|
+
position: fixed;
|
|
1787
|
+
z-index: 9999;
|
|
1788
|
+
pointer-events: none;
|
|
1789
|
+
font-size: 2rem;
|
|
1790
|
+
user-select: none;
|
|
1791
|
+
}
|
|
1792
|
+
.balloon { animation: float-up var(--duration) linear forwards; }
|
|
1793
|
+
.snowflake { animation: fall-down var(--duration) linear forwards; }
|
|
1794
|
+
</style>
|
|
1795
|
+
<script>
|
|
1796
|
+
const mode = "%MODE%";
|
|
1797
|
+
|
|
1798
|
+
// Debug logging helper
|
|
1799
|
+
const debugLog = (...args) => {
|
|
1800
|
+
if (window._debug_mode) {
|
|
1801
|
+
console.log(...args);
|
|
1802
|
+
}
|
|
1803
|
+
};
|
|
1804
|
+
|
|
1805
|
+
// [LOCK] HTMX에 CSRF 토큰 자동 추가 (Lite Mode)
|
|
1806
|
+
if (mode === 'lite' && window._csrf_token) {
|
|
1807
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
1808
|
+
document.body.addEventListener('htmx:configRequest', function(evt) {
|
|
1809
|
+
evt.detail.parameters['_csrf_token'] = window._csrf_token;
|
|
1810
|
+
});
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// Helper to clean up Plotly instances before removing elements
|
|
1815
|
+
function purgePlotly(container) {
|
|
1816
|
+
if (!window.Plotly) return;
|
|
1817
|
+
if (container.classList && container.classList.contains('js-plotly-plot')) {
|
|
1818
|
+
Plotly.purge(container);
|
|
1819
|
+
}
|
|
1820
|
+
if (container.querySelectorAll) {
|
|
1821
|
+
container.querySelectorAll('.js-plotly-plot').forEach(p => Plotly.purge(p));
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
if (mode === 'ws') {
|
|
1826
|
+
// [FIX] Pre-define sendAction with queue to handle clicks before WebSocket connects
|
|
1827
|
+
// Use window properties for debugging access
|
|
1828
|
+
window._wsReady = false;
|
|
1829
|
+
window._actionQueue = [];
|
|
1830
|
+
window._ws = null;
|
|
1831
|
+
|
|
1832
|
+
// Define sendAction IMMEDIATELY (before WebSocket connection)
|
|
1833
|
+
window.sendAction = (cid, val) => {
|
|
1834
|
+
debugLog(`[sendAction] Called with cid=${cid}, val=${val}`);
|
|
1835
|
+
|
|
1836
|
+
const payload = {
|
|
1837
|
+
type: 'click',
|
|
1838
|
+
id: cid,
|
|
1839
|
+
value: val
|
|
1840
|
+
};
|
|
1841
|
+
|
|
1842
|
+
// CSRF 토큰 추가
|
|
1843
|
+
if (window._csrf_token) {
|
|
1844
|
+
payload._csrf_token = window._csrf_token;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// [SECURE] Native 토큰 추가 (pywebview에서 필요)
|
|
1848
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
1849
|
+
const nativeToken = urlParams.get('_native_token');
|
|
1850
|
+
if (nativeToken) {
|
|
1851
|
+
payload._native_token = nativeToken;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
// Check if this is a navigation menu click (nav_menu_X)
|
|
1855
|
+
if (cid.startsWith('nav_menu')) {
|
|
1856
|
+
// Update URL hash to reflect current page
|
|
1857
|
+
// val is "page_reactive-logic", we make it #reactive-logic
|
|
1858
|
+
const pageName = val.replace('page_', '');
|
|
1859
|
+
window.location.hash = pageName;
|
|
1860
|
+
debugLog(`🔗 Updated Hash: #${pageName}`);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
// Queue action if WebSocket not ready, otherwise send immediately
|
|
1864
|
+
if (!window._wsReady || !window._ws) {
|
|
1865
|
+
debugLog(`⏳ WebSocket not ready (wsReady=${window._wsReady}, ws=${!!window._ws}), queueing action: ${cid}`);
|
|
1866
|
+
window._actionQueue.push(payload);
|
|
1867
|
+
} else {
|
|
1868
|
+
debugLog(`✅ Sending action to server: ${cid}`);
|
|
1869
|
+
window._ws.send(JSON.stringify(payload));
|
|
1870
|
+
}
|
|
1871
|
+
};
|
|
1872
|
+
|
|
1873
|
+
// Now connect WebSocket
|
|
1874
|
+
window._ws = new WebSocket((location.protocol === 'https:' ? 'wss:' : 'ws:') + "//" + location.host + "/ws");
|
|
1875
|
+
|
|
1876
|
+
// Auto-reconnect/reload logic
|
|
1877
|
+
window._ws.onclose = () => {
|
|
1878
|
+
window._wsReady = false;
|
|
1879
|
+
debugLog("🔌 Connection lost. Auto-reloading...");
|
|
1880
|
+
|
|
1881
|
+
const checkServer = () => {
|
|
1882
|
+
fetch(location.href)
|
|
1883
|
+
.then(r => {
|
|
1884
|
+
if(r.ok) {
|
|
1885
|
+
debugLog("✓ Server back online. Reloading...");
|
|
1886
|
+
window.location.reload();
|
|
1887
|
+
} else {
|
|
1888
|
+
setTimeout(checkServer, 300);
|
|
1889
|
+
}
|
|
1890
|
+
})
|
|
1891
|
+
.catch(() => setTimeout(checkServer, 300));
|
|
1892
|
+
};
|
|
1893
|
+
setTimeout(checkServer, 300);
|
|
1894
|
+
};
|
|
1895
|
+
|
|
1896
|
+
// CRITICAL: Restore from hash ONLY after WebSocket is connected
|
|
1897
|
+
window._ws.onopen = () => {
|
|
1898
|
+
debugLog("✅ [WebSocket] Connected successfully!");
|
|
1899
|
+
window._wsReady = true;
|
|
1900
|
+
|
|
1901
|
+
// Process queued actions
|
|
1902
|
+
if (window._actionQueue.length > 0) {
|
|
1903
|
+
debugLog(`📤 Processing ${window._actionQueue.length} queued action(s)...`);
|
|
1904
|
+
window._actionQueue.forEach(payload => {
|
|
1905
|
+
window._ws.send(JSON.stringify(payload));
|
|
1906
|
+
});
|
|
1907
|
+
window._actionQueue.length = 0; // Clear queue
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// Restore from hash after processing queue
|
|
1911
|
+
setTimeout(restoreFromHash, 100);
|
|
1912
|
+
};
|
|
1913
|
+
|
|
1914
|
+
// Handle WebSocket errors
|
|
1915
|
+
window._ws.onerror = (error) => {
|
|
1916
|
+
window._wsReady = false;
|
|
1917
|
+
debugLog("❌ WebSocket error:", error);
|
|
1918
|
+
};
|
|
1919
|
+
|
|
1920
|
+
window._ws.onmessage = (e) => {
|
|
1921
|
+
debugLog("[WebSocket] Message received");
|
|
1922
|
+
const msg = JSON.parse(e.data);
|
|
1923
|
+
if(msg.type === 'update') {
|
|
1924
|
+
// Check if this is a navigation update (page transition)
|
|
1925
|
+
// Server sends isNavigation flag based on action type
|
|
1926
|
+
const isNavigation = msg.isNavigation === true;
|
|
1927
|
+
|
|
1928
|
+
// Helper function to apply updates
|
|
1929
|
+
const applyUpdates = (items) => {
|
|
1930
|
+
items.forEach(item => {
|
|
1931
|
+
const el = document.getElementById(item.id);
|
|
1932
|
+
|
|
1933
|
+
// Focus Guard: Skip update if element is focused input to prevent interrupting typing
|
|
1934
|
+
if (document.activeElement && el) {
|
|
1935
|
+
const isSelfOrChild = document.activeElement.id === item.id || el.contains(document.activeElement);
|
|
1936
|
+
const isShadowChild = document.activeElement.closest && document.activeElement.closest(`#${item.id}`);
|
|
1937
|
+
|
|
1938
|
+
if (isSelfOrChild || isShadowChild) {
|
|
1939
|
+
// Check if it's actually an input that needs protection
|
|
1940
|
+
const tag = document.activeElement.tagName.toLowerCase();
|
|
1941
|
+
const isInput = tag === 'input' || tag === 'textarea' || tag.startsWith('sl-input') || tag.startsWith('sl-textarea');
|
|
1942
|
+
|
|
1943
|
+
// If it's an input, block update. If it's a button (nav menu), ALLOW update.
|
|
1944
|
+
if (isInput) {
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
if(el) {
|
|
1951
|
+
// Smart update for specific widget types to preserve animations/instances
|
|
1952
|
+
const widgetType = item.id.split('_')[0];
|
|
1953
|
+
let smartUpdated = false;
|
|
1954
|
+
|
|
1955
|
+
// Checkbox/Toggle: Update checked property only (preserve animation)
|
|
1956
|
+
if (widgetType === 'checkbox' || widgetType === 'toggle') {
|
|
1957
|
+
// Parse new HTML to extract checked state
|
|
1958
|
+
const temp = document.createElement('div');
|
|
1959
|
+
temp.innerHTML = item.html;
|
|
1960
|
+
const newCheckbox = temp.querySelector('sl-checkbox, sl-switch');
|
|
1961
|
+
|
|
1962
|
+
if (newCheckbox) {
|
|
1963
|
+
// Find the actual checkbox element (may be direct or nested)
|
|
1964
|
+
const checkboxEl = el.tagName && (el.tagName.toLowerCase() === 'sl-checkbox' || el.tagName.toLowerCase() === 'sl-switch')
|
|
1965
|
+
? el
|
|
1966
|
+
: el.querySelector('sl-checkbox, sl-switch');
|
|
1967
|
+
|
|
1968
|
+
if (checkboxEl) {
|
|
1969
|
+
const shouldBeChecked = newCheckbox.hasAttribute('checked');
|
|
1970
|
+
// Only update if different to avoid interrupting user interaction
|
|
1971
|
+
if (checkboxEl.checked !== shouldBeChecked) {
|
|
1972
|
+
checkboxEl.checked = shouldBeChecked;
|
|
1973
|
+
}
|
|
1974
|
+
smartUpdated = true;
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// Data Editor: Update AG Grid data only
|
|
1980
|
+
if (widgetType === 'data' && item.id.includes('editor')) {
|
|
1981
|
+
// item.id is like "data_editor_xxx_wrapper", extract base cid
|
|
1982
|
+
const baseCid = item.id.replace('_wrapper', '');
|
|
1983
|
+
const gridApi = window['gridApi_' + baseCid];
|
|
1984
|
+
if (gridApi) {
|
|
1985
|
+
// Extract rowData from new HTML
|
|
1986
|
+
const match = item.html.match(/rowData:\s*(\[.*?\])/s);
|
|
1987
|
+
if (match) {
|
|
1988
|
+
try {
|
|
1989
|
+
const newData = JSON.parse(match[1]);
|
|
1990
|
+
gridApi.setRowData(newData);
|
|
1991
|
+
smartUpdated = true;
|
|
1992
|
+
} catch (e) {
|
|
1993
|
+
console.error('Failed to parse AG Grid data:', e);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// Default: Full DOM replacement
|
|
2000
|
+
if (!smartUpdated) {
|
|
2001
|
+
purgePlotly(el);
|
|
2002
|
+
el.outerHTML = item.html;
|
|
2003
|
+
|
|
2004
|
+
// Execute scripts
|
|
2005
|
+
const temp = document.createElement('div');
|
|
2006
|
+
temp.innerHTML = item.html;
|
|
2007
|
+
temp.querySelectorAll('script').forEach(s => {
|
|
2008
|
+
const script = document.createElement('script');
|
|
2009
|
+
script.textContent = s.textContent;
|
|
2010
|
+
document.body.appendChild(script);
|
|
2011
|
+
script.remove();
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
});
|
|
2016
|
+
};
|
|
2017
|
+
|
|
2018
|
+
// Apply updates: use View Transitions ONLY for navigation
|
|
2019
|
+
if (isNavigation && document.body.classList.contains('anim-soft') && document.startViewTransition) {
|
|
2020
|
+
// Navigation: smooth page transition
|
|
2021
|
+
document.startViewTransition(() => applyUpdates(msg.payload));
|
|
2022
|
+
} else {
|
|
2023
|
+
// Regular updates: apply immediately without animation for snappy response
|
|
2024
|
+
applyUpdates(msg.payload);
|
|
2025
|
+
}
|
|
2026
|
+
} else if (msg.type === 'eval') {
|
|
2027
|
+
const func = new Function(msg.code);
|
|
2028
|
+
func();
|
|
2029
|
+
}
|
|
2030
|
+
};
|
|
2031
|
+
} else {
|
|
2032
|
+
// Lite Mode (HTMX) specifics
|
|
2033
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2034
|
+
document.body.addEventListener('htmx:beforeSwap', function(evt) {
|
|
2035
|
+
if (evt.detail.target) {
|
|
2036
|
+
purgePlotly(evt.detail.target);
|
|
2037
|
+
}
|
|
2038
|
+
});
|
|
2039
|
+
|
|
2040
|
+
// Hot reload support for lite mode: poll server health
|
|
2041
|
+
let serverAlive = true;
|
|
2042
|
+
const checkServerHealth = () => {
|
|
2043
|
+
// Add timestamp to prevent caching
|
|
2044
|
+
const pollUrl = location.href.split('#')[0] + (location.href.indexOf('?') === -1 ? '?' : '&') + '_t=' + Date.now();
|
|
2045
|
+
|
|
2046
|
+
fetch(pollUrl, { cache: 'no-store' })
|
|
2047
|
+
.then(r => {
|
|
2048
|
+
if (r.ok) {
|
|
2049
|
+
if (!serverAlive) {
|
|
2050
|
+
debugLog("✓ Server back online. Reloading...");
|
|
2051
|
+
document.body.style.opacity = '1'; // Restore opacity
|
|
2052
|
+
window.location.reload();
|
|
2053
|
+
}
|
|
2054
|
+
serverAlive = true;
|
|
2055
|
+
// Ensure opacity is 1 if server is alive
|
|
2056
|
+
document.body.style.opacity = '1';
|
|
2057
|
+
document.body.style.pointerEvents = 'auto';
|
|
2058
|
+
} else {
|
|
2059
|
+
throw new Error('Server error');
|
|
2060
|
+
}
|
|
2061
|
+
})
|
|
2062
|
+
.catch(() => {
|
|
2063
|
+
if (serverAlive) {
|
|
2064
|
+
debugLog("🔌 Server down. Waiting for restart...");
|
|
2065
|
+
// Dim the page to indicate connection lost
|
|
2066
|
+
document.body.style.transition = 'opacity 0.5s';
|
|
2067
|
+
document.body.style.opacity = '0.5';
|
|
2068
|
+
document.body.style.pointerEvents = 'none';
|
|
2069
|
+
}
|
|
2070
|
+
serverAlive = false;
|
|
2071
|
+
});
|
|
2072
|
+
};
|
|
2073
|
+
setInterval(checkServerHealth, 2000);
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
function toggleSidebar() {
|
|
2078
|
+
const sb = document.getElementById('sidebar');
|
|
2079
|
+
const main = document.getElementById('main');
|
|
2080
|
+
const chatInput = document.querySelector('.chat-input-container');
|
|
2081
|
+
sb.classList.toggle('collapsed');
|
|
2082
|
+
main.classList.toggle('sidebar-collapsed');
|
|
2083
|
+
// Also adjust chat input container if present
|
|
2084
|
+
if (chatInput) {
|
|
2085
|
+
chatInput.style.left = sb.classList.contains('collapsed') ? '0' : '300px';
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
function createToast(message, variant = 'primary', icon = 'info-circle') {
|
|
2090
|
+
const variantColors = { primary: '#0ea5e9', success: '#10b981', warning: '#f59e0b', danger: '#ef4444' };
|
|
2091
|
+
const toast = document.createElement('div');
|
|
2092
|
+
// Use CSS variables directly so theme changes are reflected automatically
|
|
2093
|
+
toast.style.cssText = `position: fixed; top: 20px; right: 20px; z-index: 10000; min-width: 300px; background: var(--sl-panel-background-color, var(--sl-bg-card)); color: var(--sl-text); border: 1px solid var(--sl-border); border-left: 4px solid ${variantColors[variant]}; border-radius: 4px; padding: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); display: flex; align-items: center; gap: 12px; opacity: 0; transform: translateX(400px); transition: all 0.3s;`;
|
|
2094
|
+
toast.innerHTML = `<div style="flex: 1; font-size: 14px;">${message}</div><button onclick="this.parentElement.remove()" style="background: none; border: none; cursor: pointer; padding: 4px; color: var(--sl-text-muted); font-size: 20px;">×</button>`;
|
|
2095
|
+
document.body.appendChild(toast);
|
|
2096
|
+
requestAnimationFrame(() => { toast.style.opacity = '1'; toast.style.transform = 'translateX(0)'; });
|
|
2097
|
+
setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateX(400px)'; setTimeout(() => toast.remove(), 300); }, 3300);
|
|
2098
|
+
}
|
|
2099
|
+
function createBalloons() {
|
|
2100
|
+
const emojis = ['🎈', '🎈', '🎈', '✨', '🎈', '🎈'];
|
|
2101
|
+
for (let i = 0; i < 60; i++) {
|
|
2102
|
+
const b = document.createElement('div');
|
|
2103
|
+
b.className = 'balloon';
|
|
2104
|
+
b.textContent = emojis[Math.floor(Math.random() * emojis.length)];
|
|
2105
|
+
b.style.left = Math.random() * 100 + 'vw';
|
|
2106
|
+
const startY = 10;
|
|
2107
|
+
b.style.setProperty('--start-y', startY + 'vh');
|
|
2108
|
+
const duration = 3 + Math.random() * 3;
|
|
2109
|
+
b.style.setProperty('--duration', duration + 's');
|
|
2110
|
+
b.style.animationDelay = Math.random() * 0.2 + 's';
|
|
2111
|
+
document.body.appendChild(b);
|
|
2112
|
+
setTimeout(() => b.remove(), (duration + 1) * 1000);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
function createSnow() {
|
|
2116
|
+
const emojis = ['❄️', '❅', '❆', '❄️'];
|
|
2117
|
+
for (let i = 0; i < 50; i++) {
|
|
2118
|
+
const s = document.createElement('div');
|
|
2119
|
+
s.className = 'snowflake';
|
|
2120
|
+
s.textContent = emojis[Math.floor(Math.random() * emojis.length)];
|
|
2121
|
+
s.style.left = Math.random() * 100 + 'vw';
|
|
2122
|
+
const duration = 4 + Math.random() * 4;
|
|
2123
|
+
s.style.setProperty('--duration', duration + 's');
|
|
2124
|
+
s.style.animationDelay = Math.random() * 1.0 + 's';
|
|
2125
|
+
document.body.appendChild(s);
|
|
2126
|
+
setTimeout(() => s.remove(), (duration + 5) * 1000);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// Restore state from URL Hash (or force Home if no hash)
|
|
2131
|
+
function restoreFromHash() {
|
|
2132
|
+
// 🔄 URL 해시 디코딩 (한글 등 인코딩된 문자 처리)
|
|
2133
|
+
let hash = window.location.hash.substring(1); // Remove #
|
|
2134
|
+
try {
|
|
2135
|
+
hash = decodeURIComponent(hash);
|
|
2136
|
+
} catch (e) {
|
|
2137
|
+
debugLog('Hash decode error:', e);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
// If no hash, force navigation to Home to reset server state
|
|
2141
|
+
if (!hash || hash === 'home' || hash === '홈') {
|
|
2142
|
+
debugLog('🏠 No hash - forcing Home page');
|
|
2143
|
+
const tryClickHome = (attempts = 0) => {
|
|
2144
|
+
if (attempts >= 20) return;
|
|
2145
|
+
const navButtons = document.querySelectorAll('#sidebar sl-button');
|
|
2146
|
+
if (navButtons.length > 0) {
|
|
2147
|
+
const homeBtn = navButtons[0]; // First button is Home
|
|
2148
|
+
if (homeBtn.getAttribute('variant') !== 'primary') {
|
|
2149
|
+
homeBtn.click();
|
|
2150
|
+
debugLog('🏠 Clicked Home button');
|
|
2151
|
+
}
|
|
2152
|
+
} else {
|
|
2153
|
+
setTimeout(() => tryClickHome(attempts + 1), 100);
|
|
2154
|
+
}
|
|
2155
|
+
};
|
|
2156
|
+
tryClickHome();
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
const targetKey = 'page_' + hash;
|
|
2161
|
+
debugLog(`📍 Restoring from Hash: "${hash}" (key: ${targetKey})`);
|
|
2162
|
+
|
|
2163
|
+
const tryRestore = (attempts = 0) => {
|
|
2164
|
+
// Stop after 5 seconds
|
|
2165
|
+
if (attempts >= 50) {
|
|
2166
|
+
console.warn(`⚠ Failed to restore hash "${hash}"`);
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
const navButtons = document.querySelectorAll('#sidebar sl-button');
|
|
2171
|
+
if (navButtons.length === 0) {
|
|
2172
|
+
setTimeout(() => tryRestore(attempts + 1), 100);
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
for (let btn of navButtons) {
|
|
2177
|
+
const onclick = btn.getAttribute('onclick') || '';
|
|
2178
|
+
const hxVals = btn.getAttribute('hx-vals') || '';
|
|
2179
|
+
|
|
2180
|
+
// Match either onclick (WS mode) or hx-vals (Lite mode)
|
|
2181
|
+
if (onclick.includes(targetKey) || hxVals.includes(targetKey)) {
|
|
2182
|
+
debugLog(`✓ Found target button for hash "${hash}". Clicking...`);
|
|
2183
|
+
|
|
2184
|
+
// Check if already active to avoid redundant clicks
|
|
2185
|
+
if (btn.getAttribute('variant') === 'primary') {
|
|
2186
|
+
debugLog(' - Already active, skipping click.');
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
btn.click();
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// Keep retrying in case the specific button hasn't rendered yet (unlikely if container exists)
|
|
2196
|
+
setTimeout(() => tryRestore(attempts + 1), 100);
|
|
2197
|
+
};
|
|
2198
|
+
|
|
2199
|
+
tryRestore();
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// Note: For ws mode, restoreFromHash is called from ws.onopen
|
|
2203
|
+
// For lite mode, call it on load:
|
|
2204
|
+
if (mode !== 'ws') {
|
|
2205
|
+
if (document.readyState === 'loading') {
|
|
2206
|
+
document.addEventListener('DOMContentLoaded', () => setTimeout(restoreFromHash, 200));
|
|
2207
|
+
} else {
|
|
2208
|
+
setTimeout(restoreFromHash, 200);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
</script>
|
|
2212
|
+
</head>
|
|
2213
|
+
<body>
|
|
2214
|
+
%SPLASH%
|
|
2215
|
+
<div id="root">
|
|
2216
|
+
<div id="sidebar" style="%SIDEBAR_STYLE%">
|
|
2217
|
+
%SIDEBAR_CONTENT%
|
|
2218
|
+
</div>
|
|
2219
|
+
<div id="main" class="%MAIN_CLASS%">
|
|
2220
|
+
<div id="header">
|
|
2221
|
+
<sl-icon-button name="list" style="font-size: 1.5rem; color: var(--sl-text);" onclick="toggleSidebar()"></sl-icon-button>
|
|
2222
|
+
</div>
|
|
2223
|
+
<div id="app">%CONTENT%</div>
|
|
2224
|
+
</div>
|
|
2225
|
+
</div>
|
|
2226
|
+
<div id="toast-injector" style="display:none;"></div>
|
|
2227
|
+
</body>
|
|
2228
|
+
</html>
|
|
2229
|
+
"""
|