h?l[c][f]=s+1:n.charAt(c-1)===i.charAt(f-1)?l[c][f]=l[c-1][f-1]:l[c][f]=Math.min(l[c-1][f-1]+1,Math.min(l[c][f-1]+1,l[c-1][f]+1)),l[c][f]{u();function Xg(r,e){var t=r.type,i=r.value,n,s;return e&&(s=e(r))!==void 0?s:t==="word"||t==="space"?i:t==="string"?(n=r.quote||"",n+i+(r.unclosed?"":n)):t==="comment"?"/*"+i+(r.unclosed?"":"*/"):t==="div"?(r.before||"")+i+(r.after||""):Array.isArray(r.nodes)?(n=Zg(r.nodes,e),t!=="function"?n:i+"("+(r.before||"")+n+(r.after||"")+(r.unclosed?"":")")):i}function Zg(r,e){var t,i;if(Array.isArray(r)){for(t="",i=r.length-1;~i;i-=1)t=Xg(r[i],e)+t;return t}return Xg(r,e)}Jg.exports=Zg});var ry=x((lq,ty)=>{u();var Cs="-".charCodeAt(0),_s="+".charCodeAt(0),Fl=".".charCodeAt(0),j2="e".charCodeAt(0),z2="E".charCodeAt(0);function U2(r){var e=r.charCodeAt(0),t;if(e===_s||e===Cs){if(t=r.charCodeAt(1),t>=48&&t<=57)return!0;var i=r.charCodeAt(2);return t===Fl&&i>=48&&i<=57}return e===Fl?(t=r.charCodeAt(1),t>=48&&t<=57):e>=48&&e<=57}ty.exports=function(r){var e=0,t=r.length,i,n,s;if(t===0||!U2(r))return!1;for(i=r.charCodeAt(e),(i===_s||i===Cs)&&e++;e{u();function Gy(r,e){var t=r.type,i=r.value,n,s;return e&&(s=e(r))!==void 0?s:t==="word"||t==="space"?i:t==="string"?(n=r.quote||"",n+i+(r.unclosed?"":n)):t==="comment"?"/*"+i+(r.unclosed?"":"*/"):t==="div"?(r.before||"")+i+(r.after||""):Array.isArray(r.nodes)?(n=Qy(r.nodes,e),t!=="function"?n:i+"("+(r.before||"")+n+(r.after||"")+(r.unclosed?"":")")):i}function Qy(r,e){var t,i;if(Array.isArray(r)){for(t="",i=r.length-1;~i;i-=1)t=Gy(r[i],e)+t;return t}return Gy(r,e)}Yy.exports=Qy});var Zy=x((o$,Xy)=>{u();var $s="-".charCodeAt(0),Ls="+".charCodeAt(0),su=".".charCodeAt(0),vO="e".charCodeAt(0),xO="E".charCodeAt(0);function kO(r){var e=r.charCodeAt(0),t;if(e===Ls||e===$s){if(t=r.charCodeAt(1),t>=48&&t<=57)return!0;var i=r.charCodeAt(2);return t===su&&i>=48&&i<=57}return e===su?(t=r.charCodeAt(1),t>=48&&t<=57):e>=48&&e<=57}Xy.exports=function(r){var e=0,t=r.length,i,n,s;if(t===0||!kO(r))return!1;for(i=r.charCodeAt(e),(i===Ls||i===$s)&&e++;e
At startup, its computed using the index taken from the grid_settings |
+| row_index | `index` of the row, to be used in the dataframe |
+| col_pos | `position` of the column. Index of the visible rows only.
So the columns are filtered or reordered, col_pos and col_index are no longer the same |
+| row_pos | `position` of the row. Index of the visible rows only.
So the rows are filtered, row_index and row_pos are no longer the same |
+
+## Usage Example
+
+```python
+# importing DataGrid
+from components.Datagrid import DataGrid
+
+# creating an instance of DataGrid
+data_grid = DataGrid()
+
+# importing a file
+data_grid.import_file('data.csv')
+
+# Applying filters and sorts as defined
+data_grid.filter(column_id='age', values=[30, 40])
+data_grid.sort(column_id='name', direction='asc')
+
+# Retrieving state
+state = data_grid.get_state()
+
+# Getting table for display
+table_html = data_grid.mk_table()
+```
\ No newline at end of file
diff --git a/src/components/datagrid/__init__.py b/src/components/datagrid/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/datagrid/constants.py b/src/components/datagrid/constants.py
new file mode 100644
index 0000000..c901c10
--- /dev/null
+++ b/src/components/datagrid/constants.py
@@ -0,0 +1,66 @@
+
+DATAGRID_PATH = "datagrid"
+ID_PREFIX = "datagrid"
+SORT_KEY = "sort"
+FILTER_KEY = "filter"
+WIDTH_KEY = "width"
+VISIBLE_KEY = "visible"
+INDEX_KEY = "index"
+SAFE_INDEX_KEY = "safe_index"
+TITLE_KEY = "title"
+MOVE_KEY = "move"
+ROW_INDEX_KEY = "-1"
+
+IMPORT_FILE_INPUT_KEY = "import_file_input"
+CELL_UNDER_EDITION_KEY = "cell_under_edition"
+SHEETS_NAMES_KEY = "sheets_names"
+FILTER_INPUT_CID = "__filter_input__"
+
+DG_FILTER_INPUT = "filter" # hide or show columns filtering capabilities
+DG_TABLE_HEADER = "header" # hide or show the header
+DG_TABLE_FOOTER = "footer" # hide or show the footer
+DG_ROWS_INDEXES = "row_index" # hide or show the rows indexes (default is False)
+DG_READ_ONLY = "read_only" # table in read only mode
+DG_COLUMNS = "columns" # entry for columns definitions
+DG_SELECTION_MODE = "selection_mode" # select row, column or cell (DG_SELECTION_MODE_CELL)
+DG_COLUMNS_REORDERING = "columns_reordering" # to allow or deny columns reordering
+
+DG_STATE_COLUMN_VISIBILITY = "column_visibility"
+DG_STATE_ROW_VISIBILITY = "row_visibility"
+
+DG_DATATYPE_NUMBER = "number"
+DG_DATATYPE_STRING = "string"
+DG_DATATYPE_DATETIME = "datetime"
+DG_DATATYPE_BOOL = "bool"
+DG_DATATYPE_LIST = "list"
+DG_DATATYPE_CHOICE = "choice"
+
+DG_AGGREGATE_SUM = "sum"
+DG_AGGREGATE_COUNT = "count"
+DG_AGGREGATE_MEAN = "mean"
+DG_AGGREGATE_MIN = "min"
+DG_AGGREGATE_MAX = "max"
+DG_AGGREGATE_FILTERED_SUM = "filtered_sum"
+DG_AGGREGATE_FILTERED_COUNT = "filtered_count"
+DG_AGGREGATE_FILTERED_MEAN = "filtered_mean"
+DG_AGGREGATE_FILTERED_MIN = "filtered_min"
+DG_AGGREGATE_FILTERED_MAX = "filtered_max"
+
+DG_SELECTION_MODE_CELL = "cell"
+DG_SELECTION_MODE_COLUMN = "column"
+DG_SELECTION_MODE_ROW = "row"
+
+DG_SELECTION_MODES = [DG_SELECTION_MODE_CELL, DG_SELECTION_MODE_COLUMN, DG_SELECTION_MODE_ROW]
+
+BADGES_COLORS= [
+ "#1a237e", # Muted Navy Blue
+ "#512b2b", # Burgundy Red
+ "#3b4d3f", # Olive Green
+ "#674d7d", # Dusty Purple
+ "#355c57", # Muted Teal
+ "#533f2e", # Burnt Umber
+ "#765c48", # Sandy Brown
+ "#2b3a42", # Steel Blue
+ "#263238", # Charcoal Grayish Cyan
+ "#4e4152" # Faded Mauve
+]
\ No newline at end of file
diff --git a/src/components/datagrid/icons.py b/src/components/datagrid/icons.py
new file mode 100644
index 0000000..f313b7f
--- /dev/null
+++ b/src/components/datagrid/icons.py
@@ -0,0 +1,138 @@
+from fasthtml.common import *
+
+# fluent ArrowSortDown24Regular
+icon_chevron_sort_down_regular = NotStr(
+ """""")
+
+# fluent ArrowSortDown24Filled
+icon_chevron_sort_down_filled = NotStr(
+ """""")
+
+# fluent ArrowSortUp24Regular
+icon_chevron_sort_up_regular = NotStr(
+ """""")
+
+
+# fluent ArrowSortUp24Filled
+icon_chevron_sort_up_filled = NotStr(
+ """""")
+
+# fluent Filter24Regular
+icon_filter_regular = NotStr(
+ """""")
+
+# fluent Filter24Filled
+icon_filter_filled = NotStr(
+ """""")
+
+# fluent DismissCircle24Regular
+icon_dismiss_regular = NotStr(
+ """"""
+)
+
+# fluent DismissCircle24Filled
+icon_dismiss_filled = NotStr(
+ """"""
+)
+
+# fluent TextAlignDistributed24Regular
+icon_resize_columns_regular = NotStr(
+ """"""
+)
+
+# fluent TextAlignDistributed24Regular
+icon_resize_columns_filled = NotStr(
+ """"""
+)
+
+icon_chevron_sort = NotStr("""""")
+
+icon_search = NotStr(
+ """""")
+
+# Carbon FilterRemove
+icon_filter_remove = NotStr("""
+""")
+
+# Fluent CheckboxChecked16Regular
+icon_checked = NotStr("""""")
+
+# Fluent CheckboxUnchecked16Regular
+icon_unchecked = NotStr("""""")
+
+# Fluent TableCellEdit20Regular
+icon_cell_selection = NotStr("""""")
+
+# Fluent ColumnEdit20Regular
+column_selection_mode = NotStr("""""")
+
+# Fluent
+column_triple_selection_mode = NotStr("""""")
+
+# Carbon Row
+icon_row_selection = NotStr("""""")
+
+# Carbon Column
+icon_column_selection = NotStr("""""")
+
+
+# Fluent EyeOff20Filled # to hide row or columns
+icon_hide = NotStr("""""")
+
+# Fluent Eye20Filled # to show columns or rows
+icon_show = NotStr("""""")
+
+# Fluent ArrowMove20Regular
+icon_move = NotStr("""""")
\ No newline at end of file
diff --git a/src/components/datagrid_new/DataGridApp.py b/src/components/datagrid_new/DataGridApp.py
new file mode 100644
index 0000000..7f3420b
--- /dev/null
+++ b/src/components/datagrid_new/DataGridApp.py
@@ -0,0 +1,131 @@
+import json
+import logging
+
+from fasthtml.fastapp import fast_app
+from starlette.datastructures import UploadFile
+
+from components.datagrid_new.components.FilterAll import FilterAll
+from components.datagrid_new.constants import Routes, ADD_NEW_VIEW
+from core.instance_manager import InstanceManager, debug_session
+
+datagrid_new_app, rt = fast_app()
+
+logger = logging.getLogger("DataGrid")
+
+
+@rt(Routes.Download)
+def get(session, _id: str):
+ logger.debug(f"Entering {Routes.Download} with args {debug_session(session)}, {_id=}")
+ instance = InstanceManager.get(session, _id)
+ return instance.toggle_file_upload()
+
+
+@rt(Routes.Settings)
+def get(session, _id: str):
+ logger.debug(f"Entering {Routes.Settings} with args {debug_session(session)}, {_id=}")
+ instance = InstanceManager.get(session, _id)
+ return instance.toggle_settings()
+
+
+@rt(Routes.Upload)
+async def post(session, _id: str, file: UploadFile = None):
+ file_name = file.filename if file is not None else None
+ logger.debug(f"Entering {Routes.Upload} with args {debug_session(session)}, {_id=}, {file_name=}")
+ instance = InstanceManager.get(session, _id)
+ if file is not None:
+ file_content = await file.read()
+ return instance.upload_excel_file(file_name, file_content)
+ else:
+ return instance.error_message("No file uploaded")
+
+
+@rt(Routes.UpdateFromExcel)
+def post(session, _id: str, file: UploadFile = None, sheet_name: str = None):
+ logger.debug(f"Entering {Routes.UpdateFromExcel} with args {debug_session(session)}, {_id=}")
+ instance = InstanceManager.get(session, _id)
+ return instance.on_file_upload(file, sheet_name)
+
+
+@rt(Routes.UpdateColumns)
+def post(session, _id: str, updates: str = None):
+ logger.debug(f"Entering {Routes.UpdateColumns} with args {debug_session(session)}, {_id=}, {updates=}")
+ instance = InstanceManager.get(session, _id)
+ return instance.update_columns_state(json.loads(updates))
+
+
+@rt(Routes.Filter)
+def post(session, _id: str, col_id: str, f: str):
+ """
+ Filter the content of a column using checkboxes
+ :param session:
+ :param _id:
+ :param col_id:
+ :param f:
+ :return:
+ """
+ try:
+ logger.debug(f"Entering filter with args {_id=}, {col_id=}, {f=}")
+ instance = InstanceManager.get(session, _id)
+ if isinstance(instance, FilterAll):
+ return instance.filter(f)
+
+ value = json.loads(f)
+ return instance.filter(col_id, value) # instance is DataGrid
+ except Exception as ex:
+ logger.error(ex)
+ return None
+
+
+@rt(Routes.ResetFilter)
+def post(session, _id: str, col_id: str):
+ """
+ Reset the Filter all
+ :param session:
+ :param _id:
+ :param col_id:
+ :return:
+ """
+ logger.debug(f"Entering reset filter with args {_id=}, {col_id=}")
+ instance = InstanceManager.get(session, _id)
+ if isinstance(instance, FilterAll):
+ return instance.reset()
+
+ return instance.reset_filter(col_id)
+
+
+@rt(Routes.ChangeView)
+def post(session, _id: str, view_name: str):
+ logger.debug(f"Entering change_view with args {_id=}, {view_name=}")
+ instance = InstanceManager.get(session, _id)
+ if view_name == ADD_NEW_VIEW:
+ return instance.render_create_view()
+
+ return instance.change_view(view_name if view_name not in ("", "None") else None)
+
+
+@rt(Routes.AddView)
+def post(session, _id: str, view_name: str):
+ logger.debug(f"Entering add_view with args {_id=}, {view_name=}")
+ instance = InstanceManager.get(session, _id)
+ return instance.add_view(view_name)
+
+
+@rt(Routes.UpdateView)
+def post(session, _id: str, view_name: str):
+ logger.debug(f"Entering update_view with args {_id=}, {view_name=}")
+ instance = InstanceManager.get(session, _id)
+ return instance.update_view(view_name)
+
+
+@rt(Routes.OnKeyPressed)
+def post(session, _id: str, key: str, arg: str = None):
+ logger.debug(f"Entering on_key_pressed with args {_id=}, {key=}, {arg=}")
+ instance = InstanceManager.get(session, _id)
+ return instance.manage_key_pressed(key, arg)
+
+
+@rt(Routes.OnClick)
+def post(session, _id: str, cell_id: str = None, modifier: str = None):
+ logger.debug(f"Entering on_click with args {_id=}, {cell_id=}, {modifier=}")
+ instance = InstanceManager.get(session, _id)
+ return instance.manage_click(cell_id, modifier)
diff --git a/src/components/datagrid_new/Readme.md b/src/components/datagrid_new/Readme.md
new file mode 100644
index 0000000..a057d87
--- /dev/null
+++ b/src/components/datagrid_new/Readme.md
@@ -0,0 +1,19 @@
+# id
+
+**Datagrid ids**:
+
+using `Datagrid(id=my_id)`
+
+| Name | value |
+|--------------------------------|----------------------------------------------------------------|
+| datagrid object | `get_unique_id(f"{DATAGRID_INSTANCE_ID}{session['user_id']}")` |
+| filter all | `fa_{datagrid_id}` |
+| file upload | `fu_{datagrid_id}` |
+| sidebar | `sb_{datagrid_id}` |
+| scroll bars | `scb_{datagrid_id}` |
+| Settings columns | `scol_{datagrid_id}` |
+| table | `t_{datagrid_id}` |
+| table cell drop down | `tcdd_{datagrid_id}` |
+| table drag and drop info | `tdd_{datagrid_id}` |
+| views selection component | `v_{datagrid_id}` |
+
\ No newline at end of file
diff --git a/src/components/datagrid_new/__init__.py b/src/components/datagrid_new/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/datagrid_new/assets/Datagrid.css b/src/components/datagrid_new/assets/Datagrid.css
new file mode 100644
index 0000000..2b8205b
--- /dev/null
+++ b/src/components/datagrid_new/assets/Datagrid.css
@@ -0,0 +1,325 @@
+input:focus {
+ outline: none;
+}
+
+.dt2-drag-drop {
+ display: none;
+ position: absolute;
+ top: 100%;
+ z-index: 5;
+ width: 100px;
+ border: 1px solid var(--color-base-300);
+ border-radius: 10px;
+ padding: 10px;
+ box-shadow: 0 0 40px rgba(0, 0, 0, 0.3);
+ background: var(--color-base-100);
+ box-sizing: border-box;
+ overflow-x: auto;
+ pointer-events: none; /* Prevent interfering with mouse events */
+
+}
+
+.dt2-main {
+ position: relative;
+ height: 100%;
+}
+
+.dt2-sidebar {
+ opacity: 0; /* Default to invisible */
+ visibility: hidden; /* Prevent interaction when invisible */
+ transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 75%;
+ max-height: 710px;
+ overflow-y: auto;
+ background-color: var(--color-base-100);
+ z-index: var(--datagrid-sidebar-zindex);
+ box-shadow: -5px 0 15px rgba(0, 0, 0, 0.5); /* Stronger shadow */
+ border-radius: 10px;
+}
+
+.dt2-sidebar.active {
+ opacity: 1;
+ visibility: visible;
+ transition: opacity 0.3s ease;
+}
+
+.dt2-scrollbars {
+ bottom: 0;
+ left: 0;
+ pointer-events: none; /* Ensures parents don't intercept pointer events */
+ position: absolute;
+ right: 0;
+ top: 0;
+ z-index: var(--datagrid-scrollbars-zindex);
+}
+
+/* Scrollbar Wrappers */
+.dt2-scrollbars-vertical-wrapper {
+ bottom: 3px;
+ left: auto;
+ position: absolute;
+ right: 3px;
+ top: 3px;
+ width: 8px;
+ background-color: var(--color-base-200)
+}
+
+.dt2-scrollbars-horizontal-wrapper {
+ bottom: -12px;
+ height: 8px;
+ left: 3px;
+ position: absolute;
+ right: 3px;
+ top: auto;
+ background-color: var(--color-base-200)
+}
+
+/* Vertical Scrollbar */
+.dt2-scrollbars-vertical {
+ bottom: auto;
+ left: 0;
+ position: absolute;
+ right: 0;
+ top: auto;
+ background-color: var(--color-base-300);
+ border-radius: 3px; /* Rounded corners */
+ pointer-events: auto; /* Enable interaction */
+ cursor: pointer;
+ width: 100%; /* Fits inside its wrapper */
+}
+
+/* Horizontal Scrollbar */
+.dt2-scrollbars-horizontal {
+ bottom: 0;
+ left: auto;
+ position: absolute;
+ right: auto;
+ top: 0;
+ background-color: var(--color-base-300);
+ border-radius: 3px; /* Rounded corners */
+ pointer-events: auto; /* Enable interaction */
+ cursor: pointer;
+ height: 100%; /* Fits inside its wrapper */
+}
+
+/* Scrollbar wrappers are hidden by default */
+.dt2-scrollbars-vertical-wrapper,
+.dt2-scrollbars-horizontal-wrapper {
+ opacity: 1;
+ transition: opacity 0.2s ease-in-out; /* Smooth fade in/out */
+ pointer-events: auto; /* Allow interaction */
+}
+
+/* Scrollbars */
+.dt2-scrollbars-vertical,
+.dt2-scrollbars-horizontal {
+ background-color: var(--color-resize);
+ border-radius: 3px;
+ pointer-events: auto; /* Allow interaction with the scrollbar */
+ cursor: pointer;
+}
+
+/* Scrollbar hover effects */
+.dt2-scrollbars-vertical:hover,
+.dt2-scrollbars-horizontal:hover,
+.dt2-scrollbars-vertical.dt2-dragging,
+.dt2-scrollbars-horizontal.dt2-dragging {
+ background-color: var(--color-base-content);
+}
+
+
+.dt2-table {
+ --color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
+ --color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--color-border);
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.dt2-table:focus {
+ outline: none;
+}
+
+.dt2-header,
+.dt2-footer {
+ background-color: var(--color-base-200);
+ border-radius: 10px 10px 0 0;
+ min-width: max-content;
+}
+
+.dt2-body {
+ max-height: 650px;
+ overflow: hidden; /* You can change this to auto if horizontal scrolling is required */
+ font-size: 14px;
+ min-width: max-content;
+}
+
+.dt2-row {
+ display: flex;
+ width: 100%;
+ height: 22px;
+}
+
+.dt2-cell {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ padding: 2px 8px;
+ position: relative;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ min-width: 100px;
+ flex-grow: 0;
+ flex-shrink: 1;
+ box-sizing: border-box; /* to include the borders in the computations */
+ border-bottom: 1px solid var(--color-border);
+ user-select: none;
+}
+
+.dt2-cell-content-text {
+ text-align: inherit;
+ width: 100%;
+ padding-right: 10px;
+}
+
+.dt2-cell-content-checkbox {
+ display: flex;
+ width: 100%;
+ justify-content: center; /* Horizontally center the icon */
+ align-items: center; /* Vertically center the icon */
+}
+
+.dt2-cell-content-number {
+ text-align: right;
+ width: 100%;
+ padding-right: 10px;
+}
+
+.dt2-resize-handle {
+ position: absolute;
+ right: 0;
+ top: 0;
+ width: 8px;
+ height: 100%;
+ cursor: col-resize;
+}
+
+.dt2-resize-handle::after {
+ content: ''; /* This is required */
+ position: absolute; /* Position as needed */
+ z-index: 1;
+ display: block; /* Makes it a block element */
+ width: 3px;
+ height: 60%;
+ top: calc(50% - 60% * 0.5);
+ background-color: var(--color-resize);
+}
+
+.dt2-header-hidden {
+ width: 5px;
+ background: var(--color-neutral-content);
+ border-bottom: 1px solid var(--color-border);
+ cursor: pointer;
+}
+
+.dt2-col-hidden {
+ width: 5px;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.dt2-highlight-1 {
+ color: var(--color-accent);
+}
+
+.dt2-item-handle {
+ background-image: radial-gradient(var(--color-primary-content) 40%, transparent 0);
+ background-repeat: repeat;
+ background-size: 4px 4px;
+ cursor: grab;
+ display: inline-block;
+ height: 16px;
+ margin: auto;
+ position: relative;
+ top: 1px;
+ width: 12px;
+}
+
+/* **************************************************************************** */
+/* COLUMNS SETTINGS */
+/* **************************************************************************** */
+
+.dt2-cs-header {
+ background-color: var(--color-base-200);
+ min-width: max-content;
+}
+
+.dt2-cs-columns {
+ display: grid;
+ grid-template-columns: 20px 1fr 0.5fr 0.5fr 0.5fr 0.5fr;
+}
+
+.dt2-cs-body input {
+ outline: none;
+ border-color: transparent;
+ box-shadow: none;
+}
+
+.dt2-cs-body input[type="checkbox"],
+.dt2-cs-body input.checkbox {
+ outline: initial;
+ border-color: var(--color-border);
+}
+
+
+.dt2-cs-cell {
+ padding: 0 6px 0 6px;
+ margin: auto;
+}
+
+.dt2-cs-checkbox-cell {
+ margin: auto;
+}
+
+.dt2-cs-number-cell {
+ padding: 0 6px 0 6px;
+ text-align: right;
+}
+
+.dt2-cs-select-cell {
+ padding: 0 6px;
+ margin: 3px 0;
+}
+
+.dt2-cs-body input:hover {
+ border: 1px solid #ccc; /* Provide a subtle border on focus */
+}
+
+
+.dt2-views-container-select {
+ width: 170px;
+}
+
+.dt2-views-container-create {
+ width: 300px;
+}
+
+/*.dt2-drag-drop {*/
+/* display: none;*/
+/* position: absolute;*/
+/* top: 100%;*/
+/* z-index: 5;*/
+/* width: 100px;*/
+/* border: 1px solid var(--color-border);*/
+/* border-radius: 10px;*/
+/* padding: 10px;*/
+/* box-shadow: 0 0 40px rgba(0, 0, 0, 0.3);*/
+/* background: oklch(var(--b1));*/
+/* box-sizing: border-box;*/
+/* overflow-x: auto;*/
+/* pointer-events: none; !* Prevent interfering with mouse events *!*/
+/*}*/
\ No newline at end of file
diff --git a/src/components/datagrid_new/assets/Datagrid.js b/src/components/datagrid_new/assets/Datagrid.js
new file mode 100644
index 0000000..22cd5a8
--- /dev/null
+++ b/src/components/datagrid_new/assets/Datagrid.js
@@ -0,0 +1,395 @@
+function bindDatagrid(datagridId, allowColumnsReordering) {
+ bindTooltipsWithDelegation(datagridId);
+ bindScrollbars(datagridId);
+ makeResizable(datagridId)
+}
+
+function bindScrollbars(datagridId) {
+ console.debug("bindScrollbars on element " + datagridId);
+
+ const datagrid = document.getElementById(datagridId);
+
+ if (!datagrid) {
+ console.error(`Datagrid with id "${datagridId}" not found.`);
+ return;
+ }
+
+ const verticalScrollbar = datagrid.querySelector(".dt2-scrollbars-vertical");
+ const verticalWrapper = datagrid.querySelector(".dt2-scrollbars-vertical-wrapper");
+ const horizontalScrollbar = datagrid.querySelector(".dt2-scrollbars-horizontal");
+ const horizontalWrapper = datagrid.querySelector(".dt2-scrollbars-horizontal-wrapper");
+ const body = datagrid.querySelector(".dt2-body");
+ const table = datagrid.querySelector(".dt2-table");
+
+ if (!verticalScrollbar || !verticalWrapper || !horizontalScrollbar || !horizontalWrapper || !body || !table) {
+ console.error("Essential scrollbar or content elements are missing in the datagrid.");
+ return;
+ }
+
+ let scrollingTimeout;
+
+ const computeScrollbarVisibility = () => {
+ // Determine if the content is clipped
+ const isVerticalRequired = body.scrollHeight > body.clientHeight;
+ const isHorizontalRequired = table.scrollWidth > table.clientWidth;
+
+ // Show or hide the scrollbar wrappers
+ verticalWrapper.style.display = isVerticalRequired ? "block" : "none";
+ horizontalWrapper.style.display = isHorizontalRequired ? "block" : "none";
+ };
+
+ const computeScrollbarSize = () => {
+ // Vertical scrollbar height
+ const visibleHeight = body.clientHeight;
+ const totalHeight = body.scrollHeight;
+ const wrapperHeight = verticalWrapper.offsetHeight;
+
+ if (totalHeight > 0) {
+ const scrollbarHeight = (visibleHeight / totalHeight) * wrapperHeight;
+ verticalScrollbar.style.height = `${scrollbarHeight}px`;
+ } else {
+ verticalScrollbar.style.height = `0px`;
+ }
+
+ // Horizontal scrollbar width
+ const visibleWidth = table.clientWidth;
+ const totalWidth = table.scrollWidth;
+ const wrapperWidth = horizontalWrapper.offsetWidth;
+
+ if (totalWidth > 0) {
+ const scrollbarWidth = (visibleWidth / totalWidth) * wrapperWidth;
+ horizontalScrollbar.style.width = `${scrollbarWidth}px`;
+ } else {
+ horizontalScrollbar.style.width = `0px`;
+ }
+ };
+
+ const updateVerticalScrollbarPosition = () => {
+ const maxScrollTop = body.scrollHeight - body.clientHeight;
+ const wrapperHeight = verticalWrapper.offsetHeight;
+
+ if (maxScrollTop > 0) {
+ const scrollRatio = wrapperHeight / body.scrollHeight;
+ verticalScrollbar.style.top = `${body.scrollTop * scrollRatio}px`;
+ }
+ };
+
+ const addDragEvent = (scrollbar, updateFunction) => {
+ let isDragging = false;
+ let startY = 0;
+ let startX = 0;
+
+ scrollbar.addEventListener("mousedown", (e) => {
+ isDragging = true;
+ startY = e.clientY;
+ startX = e.clientX;
+ document.body.style.userSelect = "none"; // Disable text selection while dragging scrollbars
+ scrollbar.classList.add("dt2-dragging");
+ });
+
+ document.addEventListener("mousemove", (e) => {
+ if (isDragging) {
+ const deltaY = e.clientY - startY;
+ const deltaX = e.clientX - startX;
+
+ updateFunction(deltaX, deltaY);
+
+ // Reset start points for next update
+ startY = e.clientY;
+ startX = e.clientX;
+ }
+ });
+
+ document.addEventListener("mouseup", () => {
+ isDragging = false;
+ document.body.style.userSelect = ""; // Re-enable text selection
+ scrollbar.classList.remove("dt2-dragging");
+ });
+ };
+
+ const updateVerticalScrollbar = (deltaX, deltaY) => {
+ const wrapperHeight = verticalWrapper.offsetHeight;
+ const scrollbarHeight = verticalScrollbar.offsetHeight;
+ const maxScrollTop = body.scrollHeight - body.clientHeight;
+
+ let newTop = parseFloat(verticalScrollbar.style.top || "0") + deltaY;
+ newTop = Math.max(0, Math.min(newTop, wrapperHeight - scrollbarHeight));
+
+ verticalScrollbar.style.top = `${newTop}px`;
+
+ const scrollRatio = maxScrollTop / (wrapperHeight - scrollbarHeight);
+ body.scrollTop = newTop * scrollRatio;
+ };
+
+ const updateHorizontalScrollbar = (deltaX, deltaY) => {
+ const wrapperWidth = horizontalWrapper.offsetWidth;
+ const scrollbarWidth = horizontalScrollbar.offsetWidth;
+ const maxScrollLeft = table.scrollWidth - table.clientWidth;
+
+ let newLeft = parseFloat(horizontalScrollbar.style.left || "0") + deltaX;
+ newLeft = Math.max(0, Math.min(newLeft, wrapperWidth - scrollbarWidth));
+
+ horizontalScrollbar.style.left = `${newLeft}px`;
+
+ const scrollRatio = maxScrollLeft / (wrapperWidth - scrollbarWidth);
+ table.scrollLeft = newLeft * scrollRatio;
+ };
+
+ const handleWheelScrolling = (event) => {
+ const deltaX = event.deltaX;
+ const deltaY = event.deltaY;
+
+ // Scroll the body and table content
+ body.scrollTop += deltaY; // Vertical scrolling
+ table.scrollLeft += deltaX; // Horizontal scrolling
+
+ // Update the vertical scrollbar position
+ updateVerticalScrollbarPosition();
+
+
+ // Prevent default behavior to fully manage the scroll
+ event.preventDefault();
+ };
+
+ addDragEvent(verticalScrollbar, updateVerticalScrollbar);
+ addDragEvent(horizontalScrollbar, updateHorizontalScrollbar);
+
+ body.addEventListener("wheel", handleWheelScrolling);
+
+ // Initialize scrollbars
+ computeScrollbarVisibility();
+ computeScrollbarSize();
+
+ // Recompute on window resize
+ window.addEventListener("resize", () => {
+ computeScrollbarVisibility();
+ computeScrollbarSize();
+ updateVerticalScrollbarPosition();
+
+ });
+}
+
+function makeResizable(datagridId) {
+ console.debug("makeResizable on element " + datagridId);
+
+ const tableId = 't_' + datagridId;
+ const table = document.getElementById(tableId);
+ const resizeHandles = table.querySelectorAll('.dt2-resize-handle');
+ const MIN_WIDTH = 30; // Prevent columns from becoming too narrow
+
+ // Attach event listeners using delegation
+ resizeHandles.forEach(handle => {
+ handle.addEventListener('mousedown', onStartResize);
+ handle.addEventListener('touchstart', onStartResize, {passive: false});
+ handle.addEventListener('dblclick', onDoubleClick); // Reset column width
+ });
+
+ let resizingState = null; // Maintain resizing state information
+
+ function onStartResize(event) {
+ event.preventDefault(); // Prevent unintended selections
+
+ const isTouch = event.type === 'touchstart';
+ const startX = isTouch ? event.touches[0].pageX : event.pageX;
+ const handle = event.target;
+ const cell = handle.parentElement;
+ const colIndex = cell.getAttribute('data-col');
+ const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`);
+
+ // Store initial state
+ const startWidth = cell.offsetWidth + 8;
+ resizingState = {startX, startWidth, colIndex, cells};
+
+ // Attach event listeners for resizing
+ document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onResize);
+ document.addEventListener(isTouch ? 'touchend' : 'mouseup', onStopResize);
+ }
+
+ function onResize(event) {
+ if (!resizingState) {
+ return;
+ }
+
+ const isTouch = event.type === 'touchmove';
+ const currentX = isTouch ? event.touches[0].pageX : event.pageX;
+ const {startX, startWidth, cells} = resizingState;
+
+ // Calculate new width and apply constraints
+ const newWidth = Math.max(MIN_WIDTH, startWidth + (currentX - startX));
+ cells.forEach(cell => {
+ cell.style.width = `${newWidth}px`;
+ });
+ }
+
+ function onStopResize(event) {
+ if (!resizingState) {
+ return;
+ }
+
+ const {colIndex, startWidth, cells} = resizingState;
+
+ const finalWidth = cells[0].offsetWidth;
+ console.debug(`Column ${colIndex} resized from ${startWidth}px to ${finalWidth}px`);
+
+ // Emit custom event (server communication can be tied here)
+ const resizeEvent = new CustomEvent('columnResize', {
+ detail: {colIndex, newWidth: finalWidth + 'px'},
+ });
+ table.dispatchEvent(resizeEvent);
+
+ // Clean up
+ resizingState = null;
+ document.removeEventListener('mousemove', onResize);
+ document.removeEventListener('mouseup', onStopResize);
+ document.removeEventListener('touchmove', onResize);
+ document.removeEventListener('touchend', onStopResize);
+ }
+
+ function onDoubleClick(event) {
+ const handle = event.target;
+ const cell = handle.parentElement;
+ const colIndex = cell.getAttribute('data-col');
+ const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`);
+
+ // Reset column width
+ cells.forEach(cell => {
+ cell.style.width = ''; // Use CSS default width
+ });
+
+ // Emit reset event
+ const resetEvent = new CustomEvent('columnReset', {detail: {colIndex}});
+ table.dispatchEvent(resetEvent);
+ }
+}
+
+function bindColumnsSettings(datagridId) {
+ console.debug("bindColumnsSettings on element " + datagridId);
+ const datagrid = document.querySelector(`#${datagridId}`);
+ if (!datagrid) {
+ console.error(`Datagrid with ID "${datagridId}" not found.`);
+ return;
+ }
+
+ // Target only dt2-cs-row elements inside dt2-cs-body
+ const rows = datagrid.querySelectorAll('.dt2-cs-row');
+ rows.forEach((row) => {
+ const handle = row.querySelector('.dt2-item-handle');
+ handle.setAttribute('draggable', 'true'); // Make the handle draggable
+ handle.addEventListener('dragstart', (e) => onDragStart(e, row));
+ row.addEventListener('dragover', onDragOver); // Added to target row cells as well
+ row.addEventListener('drop', (e) => onDrop(e, row));
+ });
+
+ let draggedRow = null;
+
+ function onDragStart(event, row) {
+ draggedRow = row; // Save the dragged row
+ // Add a class to highlight the dragged row
+ setTimeout(() => row.classList.add('dragging'), 0);
+ }
+
+ function onDragOver(event) {
+ event.preventDefault(); // Allow dropping
+ const container = datagrid.querySelector('.dt2-cs-body');
+
+ const targetRow = event.target.closest('.dt2-cs-row'); // Ensure we are working with rows
+ if (targetRow && targetRow !== draggedRow) {
+ const bounding = targetRow.getBoundingClientRect();
+ const offset = event.clientY - bounding.top - bounding.height / 2;
+
+ // Reorder rows based on the cursor position
+ if (offset > 0) {
+ container.insertBefore(draggedRow, targetRow.nextSibling);
+ } else {
+ container.insertBefore(draggedRow, targetRow);
+ }
+ }
+ }
+
+ function onDrop(event) {
+ event.preventDefault(); // Prevent default behavior
+ // Clean the dragging styles and reset
+ if (draggedRow) {
+ draggedRow.classList.remove('dragging');
+ draggedRow = null;
+ }
+ }
+}
+
+function getColumnsDefinitions(columnsSettingsId) {
+ console.debug("getColumnsDefinitions on element " + columnsSettingsId);
+ // Select the container element that holds all rows
+ const container = document.querySelector(`#${columnsSettingsId}`);
+
+ if (!container) {
+ console.error(`Container with id '${columnsSettingsId}' not found.`);
+ return JSON.stringify([]);
+ }
+
+ // Find all rows inside the container
+ const rows = container.querySelectorAll(".dt2-cs-row");
+ const result = [];
+
+ rows.forEach(row => {
+ // Extract column-specific data
+ const colId = row.getAttribute("data-col").trim();
+ const title = row.querySelector(`input[name="title_${colId}"]`)?.value || "";
+ const type = row.querySelector(`select[name="type_${colId}"]`)?.value || "";
+ const visible = row.querySelector(`input[name="visible_${colId}"]`)?.checked || false;
+ const usable = row.querySelector(`input[name="usable_${colId}"]`)?.checked || false;
+ const width = row.querySelector(`input[name="width_${colId}"]`)?.value || "";
+
+ // Push the row data into the result array
+ result.push({
+ col_id: colId,
+ title: title.trim(),
+ type: type.trim(),
+ visible: visible,
+ usable: usable,
+ width: parseInt(width, 10) || 0
+ });
+ });
+
+ return JSON.stringify(result); // Convert to JSON string
+}
+
+function getCellId(event) {
+ /*
+ Find the id of the dt-body-cell
+ */
+
+ function findParentByName(element, name) {
+ let parent = element;
+ while (parent) {
+ if (parent.getAttribute('name') === name) {
+ return parent;
+ }
+ parent = parent.parentElement;
+ }
+ return null; // Return null if no matching parent is found
+ }
+
+ const parentElement = findParentByName(event.target, 'dt-body-cell')
+ return parentElement ? parentElement.id : null;
+}
+
+function getClickModifier(event) {
+ if (event instanceof PointerEvent) {
+ let res = "";
+
+ // Detect AltGr specifically
+ const isAltGr = event.ctrlKey && event.altKey && !event.shiftKey && event.code === "AltRight";
+
+ if (!isAltGr) {
+ if (event.altKey) { res += "alt-" }
+ if (event.ctrlKey) { res += "ctrl-" }
+ } else {
+ res += "altgr-"; // Special case for AltGr
+ }
+
+ if (event.metaKey) { res += "meta-" }
+ if (event.shiftKey) { res += "shift-" }
+ return res;
+ }
+ return null;
+}
\ No newline at end of file
diff --git a/src/components/datagrid_new/assets/__init__.py b/src/components/datagrid_new/assets/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/datagrid_new/assets/icons.py b/src/components/datagrid_new/assets/icons.py
new file mode 100644
index 0000000..2161636
--- /dev/null
+++ b/src/components/datagrid_new/assets/icons.py
@@ -0,0 +1,56 @@
+# Fluent ArrowMove20Regular
+from fastcore.basics import NotStr
+
+icon_move = NotStr("""""")
+
+# Fluent Settings16Regular
+icon_settings = NotStr("""
+""")
+
+# Fluent FolderOpen20Regular
+icon_open = NotStr("""""")
+
+# Fluent CheckboxChecked16Regular
+icon_checked = NotStr("""""")
+
+# Fluent CheckboxUnchecked16Regular
+icon_unchecked = NotStr("""""")
+
+# Fluent Save20Regular
+icon_save = NotStr("""""")
+
+# Fluent Save20Filled
+icon_save_filled = NotStr("""""")
\ No newline at end of file
diff --git a/src/components/datagrid_new/components/ColumnsSettings.py b/src/components/datagrid_new/components/ColumnsSettings.py
new file mode 100644
index 0000000..606d3ce
--- /dev/null
+++ b/src/components/datagrid_new/components/ColumnsSettings.py
@@ -0,0 +1,102 @@
+import logging
+
+from fasthtml.components import *
+from fasthtml.xtend import Script
+
+from components.BaseComponent import BaseComponent
+from components.datagrid_new.constants import DATAGRID_INSTANCE_ID, ColumnType
+from components_helpers import mk_dialog_buttons
+from core.utils import get_unique_id
+
+logger = logging.getLogger("ColumnsSettings")
+
+
+class ColumnsSettings(BaseComponent):
+ def __init__(self, session, instance_id, owner):
+ super().__init__(session, instance_id)
+ self._owner = owner
+
+ def _mk_table_header(self):
+ return Div(
+ Div(cls="place-self-center"),
+ Div("Title", cls=" place-self-center"),
+ Div("Type", cls=" place-self-center"),
+ Div("Visible", cls=" place-self-center"),
+ Div("Usable", cls=" place-self-center"),
+ Div("Width", cls=" place-self-center"),
+ cls="dt2-cs-header dt2-cs-columns"
+ )
+
+ def _mk_table_body(self):
+ def _mk_option(option, selected_value):
+ if selected_value == option:
+ return Option(option.value, selected=True)
+ else:
+ return Option(option.value)
+
+ def _mk_columns(columns):
+ if columns is None:
+ return []
+
+ return [
+ Div(
+ A(cls="dt2-item-handle"),
+
+ Input(name=f"title_{col_state.col_id}",
+ type="input",
+ cls="dt2-cs-cell input",
+ value=col_state.title),
+
+ Select(
+ *[_mk_option(value, col_state.type) for value in ColumnType],
+ name=f"type_{col_state.col_id}",
+ cls="dt2-cs-select-cell select",
+ ),
+
+ Input(name=f"visible_{col_state.col_id}",
+ type="checkbox",
+ cls="dt2-cs-checkbox-cell toggle toggle-sm",
+ checked=col_state.visible),
+
+ Input(name=f"usable_{col_state.col_id}",
+ type="checkbox",
+ cls="dt2-cs-checkbox-cell toggle toggle-sm",
+ checked=col_state.usable),
+
+ Input(name=f"width_{col_state.col_id}",
+ type="input",
+ cls="dt2-cs-number-cell input",
+ value=col_state.width),
+
+ cls="dt2-cs-row dt2-cs-columns",
+ data_col=col_state.col_id,
+ )
+ for col_state in self._owner.get_state().columns
+ ]
+
+ return Div(*_mk_columns(self._owner.get_state().columns), cls="dt2-cs-body")
+
+ def __ft__(self):
+ on_ok = self._owner.commands.update_columns_settings(self)
+ on_cancel = self._owner.commands.cancel()
+ return Div(
+ Div(
+ H1("Columns Settings", cls="mb-3 text-xl place-self-center"),
+ Div(self._mk_table_header(),
+ self._mk_table_body(),
+ mk_dialog_buttons(ok_title="Apply", on_ok=on_ok, on_cancel=on_cancel),
+ cls="dt2-cs-container"),
+ cls="p-5",
+ ),
+ Script(f"bindColumnsSettings('{self._id}');"),
+ id=f"{self._id}",
+ )
+
+ @staticmethod
+ def create_component_id(session, prefix=None, suffix=None):
+ if prefix is None:
+ prefix = f"{DATAGRID_INSTANCE_ID}{session['user_id']}"
+ if suffix is None:
+ suffix = get_unique_id(prefix)
+
+ return f"{prefix}{suffix}"
diff --git a/src/components/datagrid_new/components/DataGrid.py b/src/components/datagrid_new/components/DataGrid.py
new file mode 100644
index 0000000..2e0a983
--- /dev/null
+++ b/src/components/datagrid_new/components/DataGrid.py
@@ -0,0 +1,636 @@
+import copy
+import logging
+from io import BytesIO
+from typing import Literal
+
+import pandas as pd
+from fasthtml.components import *
+from fasthtml.xtend import Script
+from pandas import DataFrame
+
+from components.BaseComponent import BaseComponent
+from components.datagrid_new.assets.icons import icon_move, icon_open, icon_settings, icon_checked, icon_unchecked
+from components.datagrid_new.components.ColumnsSettings import ColumnsSettings
+from components.datagrid_new.components.FileUpload import FileUpload
+from components.datagrid_new.components.FilterAll import FilterAll
+from components.datagrid_new.components.Views import Views
+from components.datagrid_new.components.commands import DataGridCommandManager
+from components.datagrid_new.constants import DATAGRID_INSTANCE_ID, ROUTE_ROOT, Routes, ColumnType, FILTER_INPUT_CID, \
+ ViewType
+from components.datagrid_new.settings import DataGridDatabaseManager, DataGridRowState, DataGridColumnState, \
+ DataGridFooterConf, DataGridState, DataGridSettings, DatagridView
+from components_helpers import mk_icon, mk_ellipsis, mk_tooltip_container
+from core.instance_manager import InstanceManager
+from core.utils import get_unique_id, make_column_id
+
+logger = logging.getLogger("DataGrid")
+
+
+class DataGrid(BaseComponent):
+ def __init__(self, session, _id: str = None, key: str = None, settings_manager=None):
+ super().__init__(session, _id)
+
+ self.commands = DataGridCommandManager(self)
+
+ self._key = key
+ self._settings_manager = settings_manager
+ self._db = DataGridDatabaseManager(session, settings_manager, key)
+ self._state: DataGridState = self._db.load_state()
+ self._settings: DataGridSettings = self._db.load_settings()
+ self._df: DataFrame | None = self._db.load_dataframe()
+
+ self._file_upload = self._create_component(FileUpload, f"fu_{self._id}")
+ self._filter_all = self._create_component(FilterAll, f"fa_{self._id}")
+ self._columns_settings = self._create_component(ColumnsSettings, f"scol_{self._id}")
+ self._views = self._create_component(Views, f"v_{self._id}")
+
+ # init
+ self.close_sidebar()
+
+ def init_from_excel(self):
+ df = pd.read_excel(BytesIO(self._file_upload.file_content),
+ sheet_name=self._file_upload.selected_sheet_name)
+ self._settings.file_name = self._file_upload.file_name
+ self._settings.selected_sheet_name = self._file_upload.selected_sheet_name
+ self._db.save_settings(self._settings)
+ return self.init_from_dataframe(df)
+
+ def init_from_dataframe(self, df: DataFrame):
+ def _get_column_type(dtype):
+ if pd.api.types.is_integer_dtype(dtype):
+ return ColumnType.Number
+ elif pd.api.types.is_float_dtype(dtype):
+ return ColumnType.Number
+ elif pd.api.types.is_bool_dtype(dtype):
+ return ColumnType.Bool
+ elif pd.api.types.is_datetime64_any_dtype(dtype):
+ return ColumnType.Datetime
+ else:
+ return ColumnType.Text # Default to Text if no match
+
+ self._df = df.copy()
+ self._df.columns = self._df.columns.map(make_column_id) # make sure column names are trimmed
+ self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index]
+ self._state.columns = [DataGridColumnState(make_column_id(col_id),
+ col_index,
+ col_id,
+ _get_column_type(self._df[make_column_id(col_id)].dtype))
+ for col_index, col_id in enumerate(df.columns)]
+ self._db.save_all(None, self._state, self._df)
+ return self
+
+ def update_columns_state(self, updates: list[dict] | None = None, mode: Literal["delta", "replace"] = "delta"):
+ """
+ Updates the state of table columns based on the provided updates. Depending on the mode
+ selected, it either applies incremental changes to existing states or replaces the
+ current state entirely with new data. The method saves the updated state to the
+ database and returns updated UI elements after processing.
+
+ :param updates: A list of dictionaries, where each dictionary represents an individual
+ column update. Each dictionary must include the `col_id` key, and can optionally
+ include any of the following keys: `title`, `visible`, `width`, `type`. If None is
+ passed, no updates are performed, and the current state is returned without changes.
+ :param mode: A string defining how updates should be applied. The value "delta" specifies
+ incremental updates to the existing state, while other values replace the state
+ entirely with new data. Defaults to "delta" other is "replace".
+ :return: A tuple containing the updated table interface (from `mk_table`) and the result
+ of `close_sidebar`.
+ """
+
+ def _update_column_def(_col_def, _update):
+ if "title" in _update:
+ _col_def.title = _update["title"]
+ if "visible" in _update:
+ _col_def.visible = _update["visible"]
+ if "usable" in _update:
+ _col_def.usable = _update["usable"]
+ if "width" in _update:
+ _col_def.width = _update["width"]
+ if "type" in _update:
+ _col_def.type = ColumnType(_update["type"])
+
+ def _get_or_create_col_def(_col_id):
+ _new_column = False
+ try:
+ _col_def = next((col for col in self._state.columns if col.col_id == col_id))
+ except StopIteration:
+ _add_column_to_dataframe_if_needed(_col_id)
+ _index = self._df.columns.get_loc(col_id)
+ _col_def = DataGridColumnState(col_id, _index)
+ self._state.columns.append(_col_def)
+ _new_column = True
+
+ return _col_def, _new_column
+
+ def _add_column_to_dataframe_if_needed(_col_id):
+ if _col_id not in self._df.columns:
+ self._df[_col_id] = None
+ return True
+ return False
+
+ new_column = False
+
+ if updates is None:
+ return self.mk_table()
+
+ if mode == "delta":
+ for update in updates:
+ col_id = update["col_id"]
+ col_def, temp_new_column = _get_or_create_col_def(col_id)
+ _update_column_def(col_def, update)
+ new_column |= temp_new_column
+
+ else:
+ new_columns_states = []
+ for update in updates:
+ col_id = update["col_id"]
+ new_column |= _add_column_to_dataframe_if_needed(col_id)
+ col_index = self._df.columns.get_loc(col_id)
+ col_def = DataGridColumnState(col_id, col_index)
+ _update_column_def(col_def, update)
+ new_columns_states.append(col_def)
+
+ self._state.columns = new_columns_states
+
+ self._views.recompute_need_save()
+
+ self._db.save_all(self._settings, self._state, self._df if new_column else None)
+
+ return self.mk_table(), self.close_sidebar(), self._views.render_select_view(oob=True)
+
+ def add_view(self, view_name, columns: list[DataGridColumnState]):
+ if view_name in [v.name for v in self._settings.views]:
+ raise ValueError(f"View '{view_name}' already exists")
+
+ new = DatagridView(view_name, ViewType.Table, copy.deepcopy(columns))
+ self._settings.views.append(new)
+ self._state.selected_view = view_name
+
+ self._db.save_all(settings=self._settings, state=self._state)
+
+ return self.mk_table()
+
+ def update_view(self, view_name, columns: list[DataGridColumnState]):
+ view = self.get_view(view_name)
+ if view is None:
+ raise ValueError(f"View '{view_name}' does not exist")
+
+ view.columns = copy.deepcopy(columns)
+
+ self._db.save_settings(self._settings)
+
+ return self.mk_table()
+
+ def change_view(self, view_name):
+
+ view = self.get_view(view_name)
+ if view is not None:
+ self._state.columns = copy.deepcopy(view.columns)
+
+ self._state.selected_view = view_name
+
+ return self.mk_table()
+
+ def filter(self, column_id: str, filtering_values: str | list[str]):
+ """
+
+ :param column_id:
+ :param filtering_values:
+ :return:
+ """
+ if filtering_values is None:
+ if column_id in self._state.filtered:
+ del self._state.filtered[column_id]
+ else:
+ self._state.filtered[column_id] = filtering_values
+
+ return self.mk_table()
+
+ def finalize_interaction(self, new_pos=None, new_input_element=None):
+ res = []
+ state = self._state
+
+ # reset sidebar if opened
+ self._state.sidebar_visible = False
+ res.append(self.mk_sidebar(None, self._state.sidebar_visible, oob=True))
+
+ # manage the selection
+ select_manager = self.mk_selection_manager(new_pos)
+ res.append(select_manager)
+
+ return res
+
+ def navigate(self, key: str | None):
+ pass
+
+ def escape(self):
+ """
+ What to do if the escape key is pressed
+ :return: Cell position to select if any, else None
+ """
+ logger.debug("Calling escape")
+ if self._state.sidebar_visible:
+ logger.debug(" escape - Sidebar will be reset")
+ return self._state.selection.selected
+
+ # if self._state.filter_popup_id is not None:
+ # logger.debug(" escape - Reset filter popup")
+ # return self._state.selected
+ #
+ # if self._state.under_edition:
+ # logger.debug(" escape - Reset under edition")
+ # self._update_under_edition(None)
+ # return self._state.selected
+ #
+ # logger.debug(" escape - Reset all")
+ # if self._state.selected is not None:
+ # self._state.last_selected = self._state.selected
+ # self._state.selected = None
+ # self._state.extra_selected.clear()
+ # return None
+
+ def manage_key_pressed(self, key, value):
+ if key == "Escape":
+ new_pos = self.escape()
+ return self.finalize_interaction(new_pos)
+
+ # make sure to return the selection manager
+ new_pos = self.navigate(None) # create the cursor if it was the first time
+ return self.finalize_interaction(new_pos)
+
+ def manage_click(self, col_index, row_index, modifier=''):
+ new_pos = self.escape()
+ return self.finalize_interaction(new_pos)
+
+ def get_state(self) -> DataGridState:
+ return self._state
+
+ def get_settings(self) -> DataGridSettings:
+ return self._settings
+
+ def get_table_id(self):
+ return f"t_{self._id}"
+
+ def get_view(self, view_name: str = None) -> DatagridView | None:
+ if view_name is None:
+ view_name = self._state.selected_view
+
+ try:
+ return next(view for view in self._settings.views if view.name == view_name)
+ except StopIteration:
+ return None
+
+ def mk_scrollbars(self):
+ return Div(
+ Div(Div(cls='dt2-scrollbars-vertical'), cls='dt2-scrollbars-vertical-wrapper'),
+ Div(Div(cls='dt2-scrollbars-horizontal'), cls='dt2-scrollbars-horizontal-wrapper'),
+ cls='dt2-scrollbars',
+ id=f"scb_{self._id}",
+ )
+
+ def mk_table(self, oob=False):
+ htmx_extra_params = {
+ "hx-on::after-settle": f"bindDatagrid('{self._id}', true);",
+ # "hx-on::before-request": "onCellEdition(event);",
+ }
+
+ def _mk_keyboard_management():
+ return Div(
+ Span(hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
+ hx_trigger=f"keydown[key=='ArrowUp'] from:.dt2-table",
+ hx_vals=f'{{"_id": "{self._id}", "key":"ArrowUp"}}',
+ hx_target=f"#tsm_{self._id}",
+ hx_swap="outerHTML"),
+ Span(hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
+ hx_trigger=f"keydown[key=='ArrowDown'] from:.dt2-table",
+ hx_vals=f'{{"_id": "{self._id}", "key":"ArrowDown"}}',
+ hx_target=f"#tsm_{self._id}",
+ hx_swap="outerHTML"),
+ Span(hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
+ hx_trigger=f"keydown[key=='ArrowLeft'] from:.dt2-table",
+ hx_vals=f'{{"_id": "{self._id}", "key":"ArrowLeft"}}',
+ hx_target=f"#tsm_{self._id}",
+ hx_swap="outerHTML"),
+ Span(hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
+ hx_trigger=f"keydown[key=='ArrowRight'] from:.dt2-table",
+ hx_vals=f'{{"_id": "{self._id}", "key":"ArrowRight"}}',
+ hx_target=f"#tsm_{self._id}",
+ hx_swap="outerHTML"),
+ Span(hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
+ hx_trigger=f"keyup[key=='Escape'] from:.dl-main",
+ hx_vals=f'{{"_id": "{self._id}", "key":"Escape"}}',
+ hx_target=f"#tsm_{self._id}",
+ hx_swap="outerHTML"),
+ Span(hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
+ hx_trigger=f"keydown[key=='Enter'] from:.dt2-table",
+ hx_vals=f"js:{{...generateKeyEventPayload('{self._id}', event)}}",
+ hx_target=f"#tsm_{self._id}",
+ hx_swap="outerHTML"),
+ Span(hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
+ hx_trigger=f"keydown[key=='Tab'] from:.dt2-table",
+ hx_vals=f"js:{{...generateKeyEventPayload('{self._id}', event)}}",
+ hx_target=f"#tsm_{self._id}",
+ hx_swap="outerHTML"),
+ ),
+
+ if self._df is None:
+ return Div(id=f"t_{self._id}")
+
+ return Div(
+ self.mk_selection_manager(),
+ Div(Label(mk_icon(icon_move), cls="flex gap-2"), id=f"tdd_{self._id}", cls="dt2-drag-drop"),
+ Div(id=f"tcdd_{self._id}"),
+ _mk_keyboard_management(),
+ Div(
+ self.mk_scrollbars(),
+ self.mk_table_header(),
+ self.mk_table_body(),
+ self.mk_table_footer(),
+ cls="dt2-inner-table"),
+ cls="dt2-table",
+ tabindex="1",
+ id=self.get_table_id(),
+ hx_swap_oob='true' if oob else None,
+ **htmx_extra_params,
+ )
+
+ def mk_table_header(self):
+ def _mk_header_name(col_def: DataGridColumnState):
+ return Div(
+ mk_ellipsis(col_def.title, name="dt2-header-title"),
+ cls="flex truncate cursor-default",
+ )
+
+ def _mk_header(col_def: DataGridColumnState):
+ if not col_def.usable:
+ return None
+ elif not col_def.visible:
+ return Div(data_col=col_def.col_id,
+ **self.commands.show_columns([col_def], cls="mmt-tooltip dt2-header-hidden"))
+ else:
+ return Div(
+ _mk_header_name(col_def),
+ Div(cls="dt2-resize-handle"),
+ style=f"width:{col_def.width}px;",
+ data_col=col_def.col_id,
+ data_tooltip=col_def.title,
+ cls="dt2-cell dt2-resizable flex",
+ )
+
+ header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden"
+ return Div(
+ *[_mk_header(col_def) for col_def in self._state.columns],
+ cls=header_class,
+ id=f"th_{self._id}"
+ )
+
+ def mk_table_body(self):
+ df = self._get_filtered_df()
+
+ return Div(
+ *[Div(
+ *[self.mk_body_cell(col_pos, row_index, col_def) for col_pos, col_def in enumerate(self._state.columns)],
+ cls="dt2-row",
+ data_row=f"{row_index}",
+ id=f"tr_{self._id}-{row_index}",
+ ) for row_index in df.index],
+ cls="dt2-body",
+ id=f"tb_{self._id}"
+ )
+
+ def mk_table_footer(self):
+ def _mk_footer(footer: DataGridFooterConf, col_def: DataGridColumnState):
+ if not col_def.usable:
+ return None
+
+ if not col_def.visible:
+ return Div(cls="dt2-col-hidden")
+
+ if col_def.col_id in footer.conf:
+ value = "Found !"
+ else:
+ value = "very long Footer"
+
+ return Div(mk_ellipsis(value),
+ data_col=col_def.col_id,
+ style=f"width:{col_def.width}px;",
+ cls="dt2-cell ",
+ )
+
+ return Div(
+ *[Div(
+ *[_mk_footer(footer, col_def) for col_def in self._state.columns],
+ id=f"tf_{self._id}",
+ cls="dt2-row dt2-row-footer",
+ ) for footer in self._state.footers or [DataGridFooterConf()]],
+ cls="dt2-footer",
+ id=f"tf_{self._id}"
+ )
+
+ def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState):
+ if not col_def.usable:
+ return None
+
+ if not col_def.visible:
+ return Div(cls="dt2-col-hidden")
+
+ content, extra_cls = self.mk_body_cell_content(col_pos, row_index, col_def)
+ cls_to_use = "dt2-cell" + (f" {extra_cls}" if extra_cls else "")
+
+ return Div(content,
+ data_col=col_def.col_id,
+ style=f"width:{col_def.width}px;",
+ cls=cls_to_use)
+
+ def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState):
+ cls = ""
+ content = ""
+
+ def mk_bool(value):
+ return Div(mk_icon(icon_checked if value else icon_unchecked, can_select=False),
+ cls="dt2-cell-content-checkbox")
+
+ def mk_text(value):
+ return mk_ellipsis(value, cls="dt2-cell-content-text")
+
+ def mk_number(value):
+ return mk_ellipsis(value, cls="dt2-cell-content-number")
+
+ def process_cell_content(value):
+ value_str = str(value)
+
+ if FILTER_INPUT_CID not in self._state.filtered or (
+ keyword := self._state.filtered[FILTER_INPUT_CID]) is None:
+ return value_str
+
+ index = value_str.lower().find(keyword.lower())
+ if index < 0:
+ return value_str
+
+ len_keyword = len(keyword)
+ res = [Span(value_str[:index])] if index > 0 else []
+ res += [Span(value_str[index:index + len_keyword], cls="dt2-highlight-1")]
+ res += [Span(value_str[index + len_keyword:])] if len(value_str) > len_keyword else []
+ return tuple(res)
+
+ if col_def.visible:
+ column_type = col_def.type
+
+ if column_type == ColumnType.Bool:
+ content = mk_bool(self._df.iloc[row_index, col_def.col_index])
+ elif column_type == ColumnType.Number:
+ content = mk_number(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
+ else:
+ content = mk_text(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
+
+ return content, cls
+
+ def mk_menu(self, oob=False):
+ return Div(
+ # *self.mk_contextual_menu_buttons(),
+ # self.mk_selection_mode_button(),
+ # self.mk_resize_table_button(),
+ # self.mk_reset_filter_button(),
+ self.mk_download_button(),
+ self.mk_settings_button(),
+ cls="flex mr-2",
+ name="dt-menu",
+ id=f"cm_{self._id}",
+ hx_swap_oob='true' if oob else None,
+ )
+
+ def mk_download_button(self):
+ return mk_icon(icon_open, **self.commands.download())
+
+ def mk_settings_button(self):
+ return mk_icon(icon_settings, **self.commands.open_settings())
+
+ def mk_sidebar(self, content, display: bool, oob=False):
+ return Div(content,
+ id=f"sb_{self._id}",
+ hx_swap_oob='true' if oob else None,
+ cls=f"dt2-sidebar {'active' if display else ''}", ),
+
+ def mk_selection_manager(self, new_pos=None, oob=False):
+ """
+ Compute what cell, row of column that must be selected.
+ :param new_pos: new col_index, row_index
+ :param oob:
+ :return:
+ """
+ logger.debug(f"Calling mk_selection_manager with {new_pos} and oob={oob}")
+
+ extra_attr = {
+ "hx-on::after-settle": f"setSelected('{self._id}');setFocus('{self._id}', event);",
+ } if new_pos else {}
+
+ s = self._state
+
+ selected = []
+ # if new_pos is not None:
+ # append_once(selected, (s.selection_mode, str(self._get_selected_id(s.selection_mode, new_pos))))
+ #
+ # # in row or column mode, we want the cell to be highlighted
+ # if s.selection_mode != DG_SELECTION_MODE_CELL:
+ # append_once(selected, (DG_SELECTION_MODE_CELL, str(self._get_selected_id(DG_SELECTION_MODE_CELL, new_pos))))
+ #
+ # # also highlight the other selected
+ # for extra_selected in s.extra_selected:
+ # selection_type, selected_id = extra_selected
+ # if selection_type == DG_SELECTION_MODE_CELL:
+ # selection_type += "x" # distinguish regular cell selection
+ # append_once(selected, (selection_type, str(selected_id)))
+
+ select_manager = Div(
+ *[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected],
+ id=f"tsm_{self._id}",
+ selection_mode=f"{s.selection.selection_mode}",
+ hx_swap_oob="outerHTML" if oob else None,
+ **extra_attr)
+
+ return select_manager
+
+ def toggle_sidebar(self, content):
+ logger.debug(f"toggle sidebar {self._id}. Previous state: {self._state.sidebar_visible}")
+ self._state.sidebar_visible = not self._state.sidebar_visible
+ content_to_use = content if self._state.sidebar_visible else None
+ return self.mk_sidebar(content_to_use, self._state.sidebar_visible)
+
+ def close_sidebar(self, oob=True):
+ logger.debug(f"close sidebar {self._id}.")
+ self._state.sidebar_visible = False
+ return self.mk_sidebar(None, self._state.sidebar_visible, oob=oob)
+
+ def toggle_file_upload(self):
+ return self.toggle_sidebar(self._file_upload)
+
+ def toggle_settings(self):
+ return self.toggle_sidebar(self._columns_settings)
+
+ def _get_filtered_df(self):
+ if self._df is None:
+ return None
+
+ df = self._df.copy()
+ df = self._apply_sort(df) # need to keep the real type to sort
+ df = self._apply_filter(df)
+
+ return df
+
+ def _apply_sort(self, df):
+ if df is None:
+ return None
+
+ sorted_columns = []
+ sorted_asc = []
+ for sort_def in self._state.sorted:
+ if sort_def.direction != 0:
+ sorted_columns.append(sort_def.column_id)
+ asc = sort_def.direction == 1
+ sorted_asc.append(asc)
+
+ if sorted_columns:
+ df = df.sort_values(by=sorted_columns, ascending=sorted_asc)
+
+ return df
+
+ def _apply_filter(self, df):
+ if df is None:
+ return None
+
+ for col_id, values in self._state.filtered.items():
+ if col_id == FILTER_INPUT_CID and values is not None:
+ visible_columns = [c.col_id for c in self._state.columns if c.visible]
+ df = df[df[visible_columns].map(lambda x: values.lower() in str(x).lower()).any(axis=1)]
+ else:
+ df = df[df[col_id].astype(str).isin(values)]
+ return df
+
+ def _create_component(self, component_type: type, component_id: str):
+ safe_create_component_id = getattr(component_type, "create_component_id")
+ return InstanceManager.get(self._session,
+ safe_create_component_id(self._session, component_id, ""),
+ component_type,
+ owner=self)
+
+ def __ft__(self):
+ return Div(
+ mk_tooltip_container(self._id),
+ Div(Div(self._filter_all, self._views, cls="flex"),
+ self.mk_menu(), cls="flex justify-between"),
+
+ Div(
+ self.mk_table(),
+ self.mk_sidebar(None, self._state.sidebar_visible),
+ cls="dt2-main",
+ ),
+
+ Script(f"bindDatagrid('{self._id}', false);"),
+ id=f"{self._id}",
+ **self.commands.on_click()
+ )
+
+ @staticmethod
+ def create_component_id(session):
+ prefix = f"{DATAGRID_INSTANCE_ID}{session['user_id']}"
+ return get_unique_id(prefix)
diff --git a/src/components/datagrid_new/components/FileUpload.py b/src/components/datagrid_new/components/FileUpload.py
new file mode 100644
index 0000000..474a066
--- /dev/null
+++ b/src/components/datagrid_new/components/FileUpload.py
@@ -0,0 +1,100 @@
+from fasthtml.components import *
+
+from assets.icons import icon_dismiss_regular
+from components.BaseComponent import BaseComponent
+from components.datagrid_new.constants import ROUTE_ROOT, Routes, DATAGRID_INSTANCE_ID
+from components_helpers import mk_dialog_buttons
+from core.utils import get_unique_id, get_sheets_names
+
+
+class FileUpload(BaseComponent):
+ def __init__(self, session, instance_id, owner: None):
+ super().__init__(session, instance_id)
+ self.file_name = None
+ self.file_content = None
+ self.sheets_names = None
+ self.selected_sheet_name = None
+ self._owner = owner
+
+ def upload_excel_file(self, file_name, file_content):
+ self.file_name = file_name
+ self.file_content = file_content
+
+ if file_content is not None:
+ self.sheets_names = get_sheets_names(file_content)
+ self.selected_sheet_name = self.sheets_names[0] if len(self.sheets_names) > 0 else 0
+
+ return self._mk_select_sheet_name_component()
+
+ def on_file_upload(self, file, sheet_name):
+ # update the selected sheet
+ self.selected_sheet_name = sheet_name
+ return self._owner.init_from_excel()
+
+ def _mk_file_upload_input(self, oob=False):
+ return Input(type='file',
+ name='file',
+ id=f"fn_{self._id}", # fn stands for 'file name'
+ value=self.file_name,
+ hx_preserve=True,
+ hx_post=f"{ROUTE_ROOT}{Routes.Upload}",
+ hx_target=f"#sn_{self._id}", # _mk_select_sheet_name_component
+ hx_swap="outerHTML",
+ hx_encoding='multipart/form-data',
+ hx_vals=f'{{"_id": "{self._id}"}}',
+ hx_swap_oob='true' if oob else None,
+ cls="file-input file-input-bordered file-input-sm w-full",
+ )
+
+ def _mk_select_sheet_name_component(self, oob=False):
+ options = [Option("Sheet name...", selected=True, disabled=True)] if self.sheets_names is None else \
+ [Option(
+ name,
+ selected=True if name == self.selected_sheet_name else None,
+ ) for name in self.sheets_names]
+
+ return Select(
+ *options,
+ name="sheet_name",
+ id=f"sn_{self._id}", # sn stands for 'sheet name'
+ cls="select select-bordered select-sm w-full ml-2"
+ )
+
+ def _mk_reset_button(self, oob=False, ):
+ return Div(icon_dismiss_regular,
+ cls="icon-24 my-auto icon-btn ml-2",
+ hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
+ hx_target=f"#{self._id}",
+ hx_swap="outerHTML",
+ hx_vals=f'{{"_id": "{self._owner.get_id()}", "key": "Escape"}}',
+ ),
+
+ def render(self, oob=False):
+ return (
+ Form(
+ H1("Import Excel file", cls="mb-3 text-xl"),
+ Div( # Container for file upload and sheet name selection
+ self._mk_file_upload_input(),
+ self._mk_select_sheet_name_component(),
+ cls="flex mb-2 w-full"
+ ),
+ mk_dialog_buttons(on_cancel=self._owner.commands.cancel()),
+ cls="flex flex-col justify-center items-center w-full p-5",
+ hx_post=f"{ROUTE_ROOT}{Routes.UpdateFromExcel}",
+ hx_target=f"#{self._owner.get_id()}", # table
+ hx_swap="outerHTML",
+ hx_vals=f'{{"_id": "{self.get_id()}"}}',
+ )
+ )
+
+ def __ft__(self):
+ return self.render()
+
+ @staticmethod
+ def create_component_id(session, prefix=None, suffix=None):
+ if prefix is None:
+ prefix = f"{DATAGRID_INSTANCE_ID}{session['user_id']}"
+ if suffix is None:
+ suffix = get_unique_id(prefix)
+
+ return f"{prefix}{suffix}"
diff --git a/src/components/datagrid_new/components/FilterAll.py b/src/components/datagrid_new/components/FilterAll.py
new file mode 100644
index 0000000..ae5e9da
--- /dev/null
+++ b/src/components/datagrid_new/components/FilterAll.py
@@ -0,0 +1,76 @@
+import logging
+
+from fasthtml.components import *
+
+from components.BaseComponent import BaseComponent
+from components.datagrid.icons import icon_filter_regular, icon_dismiss_regular
+from components.datagrid_new.constants import Routes, ROUTE_ROOT, FILTER_INPUT_CID, DATAGRID_INSTANCE_ID
+from core.utils import get_unique_id
+
+logger = logging.getLogger("FilterAll")
+
+
+class FilterAll(BaseComponent, ):
+ """
+ This class is search items in the grid
+ You can enter the items to filter and reset the filtering
+ """
+
+ def __init__(self, session, _id, owner):
+ """
+ :param datagrid:
+ """
+ super().__init__(session, _id)
+ self._owner = owner
+ logger.debug(f"FilterAll component created with id: {self._id}")
+
+ def filter(self, filter_value):
+ # be careful, the order of these two statements is important
+ return self._owner.filter(FILTER_INPUT_CID, filter_value)
+
+ def reset(self):
+ return self._owner.filter(FILTER_INPUT_CID, None), self._mk_filter_input(True)
+
+ def __ft__(self):
+ return Div(
+ self._mk_filter_input(False),
+ self._mk_reset_button(),
+ cls="flex mb-2 mr-4",
+ id=f"{self._id}", # fa stands for 'filter all'
+ )
+
+ def _mk_filter_input(self, oob=False):
+ value = self._owner.get_state().filtered.get(FILTER_INPUT_CID, None)
+ return Div(
+ Label(Div(icon_filter_regular, cls="icon-24"),
+ Input(name='f',
+ placeholder="Filter...",
+ value=value,
+ hx_post=f"{ROUTE_ROOT}{Routes.Filter}",
+ hx_trigger="keyup changed throttle:300ms",
+ hx_target=f"#t_{self._owner.get_id()}",
+ hx_swap="outerHTML",
+ hx_vals=f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}'),
+ cls="input input-sm flex gap-2"
+ ),
+ id=f"fi_{self._id}", # fa stands for 'filter all'
+ hx_swap_oob='true' if oob else None,
+ )
+
+ def _mk_reset_button(self):
+ return Div(icon_dismiss_regular,
+ cls="icon-24 my-auto icon-btn ml-2",
+ hx_post=f"{ROUTE_ROOT}{Routes.ResetFilter}",
+ hx_trigger="click",
+ hx_target=f"#t_{self._owner.get_id()}",
+ hx_swap="outerHTML",
+ hx_vals=f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}'),
+
+ @staticmethod
+ def create_component_id(session, prefix=None, suffix=None):
+ if prefix is None:
+ prefix = f"{DATAGRID_INSTANCE_ID}{session['user_id']}"
+ if suffix is None:
+ suffix = get_unique_id(prefix)
+
+ return f"{prefix}{suffix}"
diff --git a/src/components/datagrid_new/components/Views.py b/src/components/datagrid_new/components/Views.py
new file mode 100644
index 0000000..610e45e
--- /dev/null
+++ b/src/components/datagrid_new/components/Views.py
@@ -0,0 +1,132 @@
+import logging
+
+from fasthtml.components import *
+
+from components.BaseComponent import BaseComponent
+from components.datagrid_new.assets.icons import icon_save, icon_save_filled
+from components.datagrid_new.constants import DATAGRID_INSTANCE_ID, ROUTE_ROOT, Routes, ADD_NEW_VIEW
+from components_helpers import mk_select_option, mk_dialog_buttons, mk_icon
+from core.utils import get_unique_id
+
+logger = logging.getLogger("Views")
+
+
+class Views(BaseComponent):
+ def __init__(self, session, instance_id, owner):
+ super().__init__(session, instance_id)
+ self._owner = owner
+ self._need_save = False
+
+ def add_view(self, view_name):
+ self._owner.add_view(view_name, self._owner.get_state().columns)
+ self.recompute_need_save()
+ return self.render_select_view()
+
+ def change_view(self, view_name):
+ self._owner.change_view(view_name)
+ self.recompute_need_save()
+ return self._owner.mk_table(oob=True), self.render_select_view()
+
+ def update_view(self, view_name):
+ self._owner.update_view(view_name, self._owner.get_state().columns)
+ return self.render_select_view()
+
+ def recompute_need_save(self):
+ # need_save if the current ColumnsState is from the selected view
+ current_view = self._owner.get_view()
+ if current_view is None:
+ return False
+
+ saved_columns = current_view.columns
+ current_columns = self._owner.get_state().columns
+ self._need_save = not self._same(saved_columns, current_columns)
+ return self._need_save
+
+ def __ft__(self):
+ return self.render_select_view()
+
+ def render_select_view(self, oob=False, ):
+ return Div(
+
+ Select(
+ mk_select_option("Select View", selected=len(self._owner.get_settings().views) == 0, enabled=False),
+ *[mk_select_option(view.name, view.name, self._owner.get_state().selected_view)
+ for view in self._owner.get_settings().views],
+ mk_select_option(" + Add View", ADD_NEW_VIEW, None),
+ name="view_name",
+ cls="select select-sm",
+
+ hx_post=f"{ROUTE_ROOT}{Routes.ChangeView}",
+ hx_target=f"#{self._id}",
+ hx_swap="outerHTML",
+ hx_vals=f'{{"_id": "{self._id}"}}',
+ hx_trigger="change"
+ ),
+
+ mk_icon(icon_save_filled, size=24, cls="my-auto ml-2",
+ **self._owner.commands.update_view(self, self._owner.get_state().selected_view)) if self._need_save else
+ mk_icon(icon_save, size=24, cls="my-auto ml-2", can_select=False),
+
+ cls="flex mb-2 mr-4 dt2-views-container-select",
+ id=f"{self._id}",
+ hx_swap_oob='true' if oob else None,
+ )
+
+ def render_create_view(self):
+ on_ok = {
+ "hx-post": f"{ROUTE_ROOT}{Routes.AddView}",
+ "hx-target": f"#{self._id}",
+ "hx-swap": "outerHTML",
+ "hx-vals": f'{{"_id": "{self._id}"}}',
+ }
+ on_cancel = {
+ "hx-post": f"{ROUTE_ROOT}{Routes.ChangeView}",
+ "hx-target": f"#{self._id}",
+ "hx-swap": "outerHTML",
+ "hx-vals": f'{{"_id": "{self._id}", "view_name": "{self._owner.get_state().selected_view}"}}',
+ }
+ return Form(
+ Input(name="view_name",
+ type="input",
+ placeholder="View name...",
+ cls="input input-sm"),
+ mk_dialog_buttons(on_ok=on_ok, on_cancel=on_cancel),
+ cls="flex mb-2 mr-4 dt2-views-container-create",
+ id=f"{self._id}"
+ )
+
+ @staticmethod
+ def _same(saved_columns, current_columns):
+ if id(saved_columns) == id(current_columns):
+ return True
+
+ if saved_columns is None or current_columns is None:
+ return False
+
+ if len(saved_columns) != len(current_columns):
+ return False
+
+ for saved, current in zip(saved_columns, current_columns):
+ if saved.col_id != current.col_id:
+ return False
+ if saved.title != current.title:
+ return False
+ if saved.type != current.type:
+ return False
+ if saved.visible != current.visible:
+ return False
+ if saved.usable != current.usable:
+ return False
+ if saved.width != current.width:
+ return False
+
+ return True
+
+ @staticmethod
+ def create_component_id(session, prefix=None, suffix=None):
+ if prefix is None:
+ prefix = f"{DATAGRID_INSTANCE_ID}{session['user_id']}"
+ if suffix is None:
+ suffix = get_unique_id(prefix)
+
+ return f"{prefix}{suffix}"
diff --git a/src/components/datagrid_new/components/__init__.py b/src/components/datagrid_new/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/datagrid_new/components/commands.py b/src/components/datagrid_new/components/commands.py
new file mode 100644
index 0000000..9136af2
--- /dev/null
+++ b/src/components/datagrid_new/components/commands.py
@@ -0,0 +1,127 @@
+import json
+
+from components.datagrid_new.constants import ROUTE_ROOT, Routes
+
+
+class DataGridCommandManager:
+ def __init__(self, datagrid):
+ self.datagrid = datagrid
+ self._id = self.datagrid.get_id()
+
+ def cancel(self):
+ return {
+ "hx-post": f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
+ "hx-target": f"#tsm_{self._id}",
+ "hx-swap": "outerHTML",
+ "hx-vals": f'{{"_id": "{self._id}", "key": "Escape"}}',
+ }
+
+ def download(self):
+ return {
+ "hx-get": f"{ROUTE_ROOT}{Routes.Download}",
+ "hx-target": f"#sb_{self._id}",
+ "hx-swap": "outerHTML",
+ "hx-vals": f'{{"_id": "{self._id}"}}',
+ "hx-trigger": "click consume",
+ "data_tooltip": "Open Excel file",
+ "class": "mmt-tooltip"
+ }
+
+ def update_view(self, component, view_name):
+ return {
+ "hx-post": f"{ROUTE_ROOT}{Routes.UpdateView}",
+ "hx-target": f"#{component.get_id()}",
+ "hx-swap": "outerHTML",
+ "hx-vals": f'{{"_id": "{component.get_id()}", "view_name": "{view_name}"}}',
+ "data_tooltip": "Update view",
+ }
+
+ def add_view(self, view_name, columns):
+ pass
+
+ def open_settings(self):
+ return {
+ "hx-get": f"{ROUTE_ROOT}{Routes.Settings}",
+ "hx-target": f"#sb_{self._id}",
+ "hx-swap": "outerHTML",
+ "hx-vals": f'{{"_id": "{self._id}"}}',
+ "hx-trigger": "click consume",
+ "data_tooltip": "Open settings",
+ "class": "mmt-tooltip"
+ }
+
+ def update_columns_settings(self, component):
+ return {
+ "hx-post": f"{ROUTE_ROOT}{Routes.UpdateColumns}",
+ "hx-target": f"#{self.datagrid.get_table_id()}", # table
+ "hx-swap": "outerHTML",
+ "hx-vals": f'js:{{"_id": "{self._id}", "updates": getColumnsDefinitions("{component.get_id()}")}}',
+ }
+
+ def hide_columns(self, col_defs: list, cls=""):
+ return self._get_hide_show_columns_attrs("Hide", col_defs, "false", cls=cls)
+
+ def show_columns(self, col_defs: list, cls=""):
+ return self._get_hide_show_columns_attrs("Show", col_defs, "true", cls=cls)
+
+ def reset_filters(self, cls=""):
+ return {"hx_post": f"{ROUTE_ROOT}{Routes.ResetFilter}",
+ "hx_vals": f'{{"g_id": "{self._id}"}}',
+ "hx_target": f"#t_{self._id}",
+ "hx_swap": "outerHTML",
+ "data_tooltip": "Reset all filters",
+ "cls": self.merge_class(cls, "dt-tooltip")}
+
+ def on_click(self):
+ return {
+ "hx-post": f"{ROUTE_ROOT}{Routes.OnClick}",
+ "hx-target": f"#tsm_{self._id}",
+ "hx-trigger"
+ "hx-swap": "outerHTML",
+ "hx-vals": f'js:{{_id: "{self._id}", cell_id:getCellId(event), modifier:getClickModifier(event)}}',
+ }
+
+ def _get_hide_show_columns_attrs(self, mode, col_defs: list, new_value, cls=""):
+ str_col_names = ", ".join(f"'{col_def.title}'" for col_def in col_defs)
+ tooltip_msg = f"{mode} column{'s' if len(col_defs) > 1 else ''} {str_col_names}"
+ updates = [{"col_id": col_def.col_id, "visible": new_value} for col_def in col_defs]
+ return {
+ "hx_post": f"{ROUTE_ROOT}{Routes.UpdateColumns}",
+ "hx_vals": f"js:{{'_id': '{self._id}', 'updates':'{json.dumps(updates)}' }}",
+ "hx_target": f"#t_{self._id}",
+ "hx_swap": "outerHTML",
+ "data_tooltip": tooltip_msg,
+ "cls": self.merge_class(cls, "mmt-tooltip")
+ }
+
+ @staticmethod
+ def merge(*items):
+ """
+ Merges multiple dictionaries into a single dictionary by combining their key-value pairs.
+ If a key exists in multiple dictionaries and its value is a string, the values are concatenated.
+ If the key's value is not a string, an error is raised.
+
+ :param items: dictionaries to be merged. If all items are None, None is returned.
+ :return: A single dictionary containing the merged key-value pairs from all input dictionaries.
+ :raises NotImplementedError: If a key's value is not a string and exists in multiple input dictionaries.
+ """
+ if all(item is None for item in items):
+ return None
+
+ res = {}
+ for item in [item for item in items if item is not None]:
+
+ for key, value in item.items():
+ if not key in res:
+ res[key] = value
+ else:
+ if isinstance(res[key], str):
+ res[key] += " " + value
+ else:
+ raise NotImplementedError("")
+
+ return res
+
+ @staticmethod
+ def merge_class(cls1, cls2):
+ return (cls1 + " " + cls2) if cls2 else cls1
diff --git a/src/components/datagrid_new/constants.py b/src/components/datagrid_new/constants.py
new file mode 100644
index 0000000..e33c47e
--- /dev/null
+++ b/src/components/datagrid_new/constants.py
@@ -0,0 +1,36 @@
+from enum import Enum
+
+DATAGRID_INSTANCE_ID = "__Datagrid__"
+ROUTE_ROOT = "/datagrid_new"
+FILTER_INPUT_CID = "__filter_input__"
+DEFAULT_COLUMN_WIDTH = 100
+ADD_NEW_VIEW = "__add_new_view__"
+
+class Routes:
+ Filter = "/filter" # request the filtering in the grid
+ ResetFilter = "/reset_filter" #
+ OnKeyPressed = "/on_key_pressed"
+ OnClick = "/on_click"
+ Settings = "/settings"
+ Upload = "/upload"
+ Download = "/download"
+ UpdateFromExcel = "/update_from_excel"
+ UpdateColumns = "/update_columns"
+ ChangeView = "/change_view"
+ AddView = "/add_view"
+ UpdateView = "/update_view"
+
+
+class ColumnType(Enum):
+ RowIndex = "RowIndex"
+ Text = "Text"
+ Number = "Number"
+ Datetime = "DateTime"
+ Bool = "Boolean"
+ Choice = "Choice"
+ List = "List"
+
+class ViewType(Enum):
+ Table = "Table"
+ Chart = "Chart"
+ Form = "Form"
diff --git a/src/components/datagrid_new/settings.py b/src/components/datagrid_new/settings.py
new file mode 100644
index 0000000..5c483b1
--- /dev/null
+++ b/src/components/datagrid_new/settings.py
@@ -0,0 +1,128 @@
+import dataclasses
+import json
+from io import StringIO
+
+import pandas as pd
+from pandas import DataFrame
+
+from components.datagrid_new.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType, ROUTE_ROOT, Routes
+from core.settings_management import SettingsManager, SettingsTransaction
+from core.utils import make_column_id
+
+DATAGRID_SETTINGS_ENTRY = "DatagridSettings"
+
+
+@dataclasses.dataclass
+class DataGridRowState:
+ row_id: int
+ visible: bool = True
+ height: int | None = None
+
+
+@dataclasses.dataclass
+class DataGridColumnState:
+ col_id: str # name of the column: cannot be changed
+ col_index: int # index of the column in the dataframe: cannot be changed
+ title: str = None
+ type: ColumnType = ColumnType.Text
+ visible: bool = True
+ usable: bool = True
+ width: int = DEFAULT_COLUMN_WIDTH
+
+
+@dataclasses.dataclass
+class DatagridEditionState:
+ under_edition: tuple[int, int] | None = None
+ previous_under_edition: tuple[int, int] | None = None
+
+
+@dataclasses.dataclass
+class DatagridSelectionState:
+ selected: tuple[int, int] | None = None
+ last_selected: tuple[int, int] | None = None
+ selection_mode: str = None # valid values are "row", "column" or None for "cell"
+ extra_selected: list[tuple[str, str | int]] = dataclasses.field(
+ default_factory=list) # list(tuple(selection_mode, element_id))
+ last_extra_selected: tuple[int, int] = None
+
+
+@dataclasses.dataclass
+class DataGridFooterConf:
+ conf: dict[str, str] = dataclasses.field(default_factory=dict)
+
+
+@dataclasses.dataclass
+class DatagridView:
+ name: str
+ type: ViewType = ViewType.Table
+ columns: list[DataGridColumnState] = None
+
+
+@dataclasses.dataclass
+class DataGridSettings:
+ file_name: str = None
+ selected_sheet_name: str = None
+ header_visible: bool = True
+ views: list[DatagridView] = dataclasses.field(default_factory=list)
+
+
+@dataclasses.dataclass
+class DataGridState:
+ sidebar_visible: bool = False
+ selected_view: str = None
+ columns: list[DataGridColumnState] = None
+ rows: list[DataGridRowState] = None # only the rows that have a specific state
+ footers: list[DataGridFooterConf] = dataclasses.field(default_factory=list)
+ sorted: list = dataclasses.field(default_factory=list)
+ filtered: dict = dataclasses.field(default_factory=dict)
+ edition: DatagridEditionState = dataclasses.field(default_factory=DatagridEditionState)
+ selection: DatagridSelectionState = dataclasses.field(default_factory=DatagridSelectionState)
+
+
+class DataGridDatabaseManager:
+ def __init__(self, session: dict, settings_manager: SettingsManager, key: str):
+ self._session = session
+ self._settings_manager = settings_manager
+ self._key = "#".join(make_column_id(item) for item in key)
+
+ def save_settings(self, settings: DataGridSettings):
+ self._settings_manager.put(self._session, self.get_settings_entry(), settings)
+
+ def save_state(self, state: DataGridState):
+ self._settings_manager.put(self._session, self.get_state_entry(), state)
+
+ def save_dataframe(self, df: DataFrame):
+ self._settings_manager.put(self._session, self.get_data_entry(), df.to_json())
+
+ def save_all(self, settings: DataGridSettings = None, state: DataGridState = None, df: DataFrame = None):
+ with SettingsTransaction(self._session, self._settings_manager) as st:
+ if settings is not None:
+ st.put(self.get_settings_entry(), settings)
+ if state is not None:
+ st.put(self.get_state_entry(), state)
+ if df is not None:
+ st.put(self.get_data_entry(), df.to_json())
+
+ def load_settings(self):
+ return self._settings_manager.get(self._session, self.get_settings_entry(), default=DataGridSettings())
+
+ def load_state(self):
+ return self._settings_manager.get(self._session, self.get_state_entry(), default=DataGridState())
+
+ def load_dataframe(self):
+ as_json = self._settings_manager.get(self._session, self.get_data_entry(), default=None)
+ if as_json is None:
+ return None
+
+ df = pd.read_json(StringIO(as_json))
+ return df
+
+ def get_settings_entry(self):
+ return f"{DATAGRID_SETTINGS_ENTRY}_{self._key}_settings"
+
+ def get_state_entry(self):
+ return f"{DATAGRID_SETTINGS_ENTRY}_{self._key}_state"
+
+ def get_data_entry(self):
+ return f"{DATAGRID_SETTINGS_ENTRY}_{self._key}_data"
+
diff --git a/src/components/debugger/DebuggerApp.py b/src/components/debugger/DebuggerApp.py
new file mode 100644
index 0000000..54b7de6
--- /dev/null
+++ b/src/components/debugger/DebuggerApp.py
@@ -0,0 +1,17 @@
+import logging
+
+from fasthtml.fastapp import fast_app
+
+from components.debugger.constants import Routes
+from core.instance_manager import InstanceManager
+
+debugger_app, rt = fast_app()
+
+logger = logging.getLogger("Debugger")
+
+
+@rt(Routes.DbEngine)
+def post(session, _id: str, digest: str = None):
+ logger.debug(f"Entering {Routes.DbEngine} with args {_id=}, {digest=}")
+ instance = InstanceManager.get(session, _id)
+ return instance.add_tab(digest)
diff --git a/src/components/debugger/__init__.py b/src/components/debugger/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/debugger/assets/Debugger.js b/src/components/debugger/assets/Debugger.js
new file mode 100644
index 0000000..e56cc70
--- /dev/null
+++ b/src/components/debugger/assets/Debugger.js
@@ -0,0 +1,79 @@
+// Import the svelte-jsoneditor module
+import {createJSONEditor} from 'https://cdn.jsdelivr.net/npm/vanilla-jsoneditor/standalone.js';
+
+/**
+ * Initializes and displays a JSON editor using the Svelte JSON Editor.
+ * https://github.com/josdejong/svelte-jsoneditor
+ * @param {string} debuggerId - The ID of the container where the editor should be rendered.
+ * @param {string} targetID - The ID of the component that will receive the response (tab manager)
+ * @param {Object} data - The JSON data to be rendered in the editor.
+ */
+function showJson(debuggerId, targetID, data) {
+ const containerId = `dbengine-${debuggerId}`
+ const container = document.getElementById(containerId);
+ if (!container) {
+ console.error(`Container with ID '${containerId}' not found.`);
+ return;
+ }
+
+ // Clear previous content (if any)
+ container.innerHTML = '';
+
+ // Create and render the editor
+ const editor = createJSONEditor({
+ target: container,
+ props: {
+ content: {json: data},
+ mode: 'view', // Options: 'view', 'tree', 'text'
+ readOnly: true,
+ onSelect: (selection) => {
+ // Access the complete JSON
+ const jsonContent = editor.get()?.json || {};
+ const {key, value} = getSelectedNodeValue(selection, jsonContent);
+
+ htmx.ajax('POST', '/debugger/dbengine', {
+ target: `#${targetID}`,
+ headers: {"Content-Type": "application/x-www-form-urlencoded"},
+ swap: "outerHTML",
+ values: {
+ _id: debuggerId,
+ digest: value,
+ }
+ });
+ },
+
+
+ },
+ });
+
+ console.log('Svelte JSON Editor initialized with data:', data);
+}
+
+/**
+ * Retrieves the selected key and value based on the editor's selection details and JSON structure.
+ *
+ * @param {Object} selection - The editor's selection object.
+ * @param {Object} jsonContent - The JSON content from the editor.
+ * @returns {{key: string|null, value: any|null}} - The selected key and value.
+ */
+function getSelectedNodeValue(selection, jsonContent) {
+ if (!selection || !jsonContent) {
+ return {key: null, value: null};
+ }
+
+ if (selection.path) {
+ // If a full path is provided (e.g., ["items", 0, "value"])
+ const key = selection.path[selection.path.length - 1]; // The last item is the key
+ const value = selection.path.reduce((current, segment) => {
+ return current ? current[segment] : undefined;
+ }, jsonContent);
+ return {key, value};
+ }
+
+ // For single key/value selections
+ return {key: selection.key || null, value: jsonContent[selection.key] || null};
+}
+
+
+window.showJson = showJson;
+
diff --git a/src/components/debugger/assets/__init__.py b/src/components/debugger/assets/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/debugger/assets/icons.py b/src/components/debugger/assets/icons.py
new file mode 100644
index 0000000..5743071
--- /dev/null
+++ b/src/components/debugger/assets/icons.py
@@ -0,0 +1,9 @@
+from fastcore.basics import NotStr
+
+# DatabaseSearch20Regular
+icon_dbengine = NotStr("""""")
diff --git a/src/components/debugger/commands.py b/src/components/debugger/commands.py
new file mode 100644
index 0000000..a716c1f
--- /dev/null
+++ b/src/components/debugger/commands.py
@@ -0,0 +1,16 @@
+from components.debugger.constants import ROUTE_ROOT, Routes
+
+
+class Commands:
+ def __init__(self, owner):
+ self._owner = owner
+ self._id = owner.get_id()
+
+
+ def show_dbengine(self):
+ return {
+ "hx-post": f"{ROUTE_ROOT}{Routes.DbEngine}",
+ "hx-target": f"#{self._owner.tabs_manager.get_id()}",
+ "hx-swap": "outerHTML",
+ "hx-vals": f'{{"_id": "{self._id}"}}',
+ }
\ No newline at end of file
diff --git a/src/components/debugger/components/DbEngineDebugger.py b/src/components/debugger/components/DbEngineDebugger.py
new file mode 100644
index 0000000..f9a739a
--- /dev/null
+++ b/src/components/debugger/components/DbEngineDebugger.py
@@ -0,0 +1,18 @@
+from fasthtml.components import *
+
+from components.BaseComponent import BaseComponent
+from core.instance_manager import InstanceManager
+
+
+class DbEngineDebugger(BaseComponent):
+ def __init__(self, session, _id, owner, data):
+ super().__init__(session, _id)
+ self._owner = owner
+ self.data = data
+
+ def __ft__(self):
+ return Div(id=f"dbengine-{self._id}")
+
+ def on_htmx_after_settle(self):
+ return f"showJson('{self._id}', '{self._owner.tabs_manager.get_id()}', {self.data});"
+
\ No newline at end of file
diff --git a/src/components/debugger/components/Debugger.py b/src/components/debugger/components/Debugger.py
new file mode 100644
index 0000000..9c18a2d
--- /dev/null
+++ b/src/components/debugger/components/Debugger.py
@@ -0,0 +1,52 @@
+import json
+import logging
+
+from fasthtml.components import *
+
+from components.BaseComponent import BaseComponent
+from components.debugger.assets.icons import icon_dbengine
+from components.debugger.commands import Commands
+from components.debugger.components.DbEngineDebugger import DbEngineDebugger
+from components.debugger.constants import DBENGINE_DEBUGGER_INSTANCE_ID
+from components_helpers import mk_ellipsis, mk_icon
+from core.utils import get_unique_id
+
+logger = logging.getLogger("Debugger")
+
+class Debugger(BaseComponent):
+ def __init__(self, session, _id, settings_manager, tabs_manager):
+ super().__init__(session, _id)
+ self.settings_manager = settings_manager
+ self.db_engine = settings_manager.get_db_engine()
+ self.tabs_manager = tabs_manager
+ self.commands = Commands(self)
+
+ def add_tab(self, digest):
+ content = self.mk_db_engine(digest)
+ tab_key = f"debugger-dbengine-{digest}"
+ title = f"DBEngine-{digest if digest else 'head'}"
+ self.tabs_manager.add_tab(title, content, key=tab_key)
+ return self.tabs_manager.render()
+
+ def mk_db_engine(self, digest):
+ data = self.db_engine.debug_load(digest) if digest else self.db_engine.debug_head()
+ logger.debug(f"mk_db_engine: {data}")
+ return DbEngineDebugger(self._session, self._id, self, json.dumps(data))
+
+ def __ft__(self):
+ return Div(
+ Div(cls="divider"),
+ mk_ellipsis("Debugger", cls="text-sm font-medium mb-1"),
+ Div(
+ mk_icon(icon_dbengine, can_select=False), mk_ellipsis("DbEngine"),
+ cls="flex truncate",
+ **self.commands.show_dbengine(),
+ ),
+
+ id=self._id,
+ )
+
+ @staticmethod
+ def create_component_id(session):
+ prefix = f"{DBENGINE_DEBUGGER_INSTANCE_ID}{session['user_id']}"
+ return get_unique_id(prefix)
diff --git a/src/components/debugger/components/__init__.py b/src/components/debugger/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/debugger/constants.py b/src/components/debugger/constants.py
new file mode 100644
index 0000000..6dc29ce
--- /dev/null
+++ b/src/components/debugger/constants.py
@@ -0,0 +1,6 @@
+DBENGINE_DEBUGGER_INSTANCE_ID = "debugger"
+ROUTE_ROOT = "/debugger"
+
+class Routes:
+ DbEngine = "/dbengine" # request the filtering in the grid
+
\ No newline at end of file
diff --git a/src/components/drawerlayout/DrawerLayoutApp.py b/src/components/drawerlayout/DrawerLayoutApp.py
new file mode 100644
index 0000000..44f7828
--- /dev/null
+++ b/src/components/drawerlayout/DrawerLayoutApp.py
@@ -0,0 +1,3 @@
+from fasthtml.fastapp import fast_app
+
+drawer_layout_app, rt = fast_app()
\ No newline at end of file
diff --git a/src/components/drawerlayout/DrawerLayoutPage.py b/src/components/drawerlayout/DrawerLayoutPage.py
new file mode 100644
index 0000000..73bc416
--- /dev/null
+++ b/src/components/drawerlayout/DrawerLayoutPage.py
@@ -0,0 +1,16 @@
+class DrawerLayoutPage:
+ def __init__(self, name, component, /, _id=None, path: str = None, icon=None):
+ """
+
+ :param name:
+ :param component:
+ :param _id:
+ :param path:
+ :param icon:
+ """
+ self.name = name
+ self.title = name
+ self.component = component
+ self.id = _id
+ self.path = path
+ self.icon = icon
diff --git a/src/components/drawerlayout/__init__.py b/src/components/drawerlayout/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/drawerlayout/assets/DrawerLayout.css b/src/components/drawerlayout/assets/DrawerLayout.css
new file mode 100644
index 0000000..34a6d80
--- /dev/null
+++ b/src/components/drawerlayout/assets/DrawerLayout.css
@@ -0,0 +1,67 @@
+main {
+ display: flex; /* Allows children to use the parent's height */
+ flex-grow: 1; /* Ensures it grows to fill available space */
+ height: 100%; /* Inherit height from its parent */
+ width: 100%;
+}
+
+.dl-container {
+ display: flex; /* Allows children to use the parent's height */
+ flex-grow: 1; /* Ensures it grows to fill available space */
+ width: 100%;
+}
+
+.dl-main {
+ flex-grow: 1; /* Ensures it grows to fill available space */
+ height: 100%; /* Inherit height from its parent */
+ overflow-x: auto;
+}
+
+.dl-main:focus {
+ outline: none;
+}
+
+.dl-sidebar {
+ position: relative;
+ width: 150px;
+ flex-shrink: 0; /* Prevent sidebar from shrinking */
+ flex-grow: 0; /* Disable growth (optional for better control) */
+ transition: width 0.2s ease;
+ height: 100%; /* Makes the sidebar height span the entire viewport */
+}
+
+.dl-sidebar.collapsed {
+ overflow: hidden;
+ width: 0 !important;
+ padding: 0;
+}
+
+.dl-sidebar.collapsed .dl-splitter {
+ display: none; /* Hides the splitter when sidebar is collapsed */
+}
+
+
+.dl-splitter {
+ position: absolute;
+ right: 0;
+ top: 0;
+ width: 4px;
+ height: 100%;
+ cursor: col-resize;
+ background-color: color-mix(in oklab, var(--color-base-content) 50%, #0000);
+}
+
+.dl-splitter::after {
+ --color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
+ content: ''; /* This is required */
+ position: absolute; /* Position as needed */
+ z-index: 1;
+ display: block; /* Makes it a block element */
+ width: 3px;
+ background-color: color-mix(in oklab, var(--color-base-content) 50%, #0000);
+}
+
+
+.dl-splitter:hover {
+ background-color: #aaa; /* Change color on hover */
+}
diff --git a/src/components/drawerlayout/assets/DrawerLayout.js b/src/components/drawerlayout/assets/DrawerLayout.js
new file mode 100644
index 0000000..2d9c088
--- /dev/null
+++ b/src/components/drawerlayout/assets/DrawerLayout.js
@@ -0,0 +1,50 @@
+function bindDrawerLayout(drawerId) {
+ makeDrawerResizable(drawerId);
+}
+
+function makeDrawerResizable(drawerId) {
+ console.debug("makeResizable on element " + drawerId);
+
+ const sidebar = document.getElementById(`sidebar_${drawerId}`);
+ const splitter = document.getElementById(`splitter_${drawerId}`);
+ let isResizing = false;
+
+ if (!sidebar || !splitter) {
+ console.error("Invalid sidebar or splitter element.");
+ return;
+ }
+
+ splitter.addEventListener("mousedown", (e) => {
+ e.preventDefault();
+ isResizing = true;
+ document.body.style.cursor = "col-resize"; // Change cursor style globally
+ document.body.style.userSelect = "none"; // Disable text selection
+
+ });
+
+ document.addEventListener("mousemove", (e) => {
+ if (!isResizing) return;
+
+ // Get the new width for the sidebar based on mouse movement
+ const containerRect = sidebar.parentNode.getBoundingClientRect();
+ let newWidth = e.clientX - containerRect.left;
+
+ // Set minimum and maximum width constraints for the sidebar
+ if (newWidth < 100) {
+ newWidth = 100; // Minimum width
+ } else if (newWidth > 220) {
+ newWidth = 220; // Maximum width
+ }
+
+ sidebar.style.width = `${newWidth}px`;
+ });
+
+ document.addEventListener("mouseup", () => {
+ if (isResizing) {
+ isResizing = false; // Stop resizing
+ document.body.style.cursor = ""; // Reset cursor
+ document.body.style.userSelect = ""; // Re-enable text selection
+ }
+ });
+
+}
\ No newline at end of file
diff --git a/src/components/drawerlayout/assets/__init__.py b/src/components/drawerlayout/assets/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/drawerlayout/assets/icons.py b/src/components/drawerlayout/assets/icons.py
new file mode 100644
index 0000000..946752d
--- /dev/null
+++ b/src/components/drawerlayout/assets/icons.py
@@ -0,0 +1,19 @@
+from fastcore.basics import NotStr
+
+# Fluent - PanelLeftContract20Regular
+icon_panel_contract_regular = NotStr(
+ """""")
+
+# Fluent PanelLeftExpand20Regular
+icon_panel_expand_regular = NotStr(
+ """""")
diff --git a/src/components/drawerlayout/components/DrawerLayout.py b/src/components/drawerlayout/components/DrawerLayout.py
new file mode 100644
index 0000000..88679ab
--- /dev/null
+++ b/src/components/drawerlayout/components/DrawerLayout.py
@@ -0,0 +1,69 @@
+from fasthtml.components import *
+from fasthtml.xtend import Script
+
+from components.BaseComponent import BaseComponent
+from components.addstuff.components.AddStuffMenu import AddStuffMenu
+from components.addstuff.components.Repositories import Repositories
+from components.debugger.components.Debugger import Debugger
+from components.drawerlayout.assets.icons import icon_panel_contract_regular, icon_panel_expand_regular
+from components.drawerlayout.constants import DRAWER_LAYOUT_INSTANCE_ID
+from components.tabs.components.MyTabs import MyTabs
+from core.instance_manager import InstanceManager
+from core.settings_management import SettingsManager
+
+
+class DrawerLayout(BaseComponent):
+
+ def __init__(self, session: dict | None,
+ _id: str = None,
+ settings_manager: SettingsManager = None):
+ super().__init__(session, _id)
+ self._tabs = InstanceManager.get(session, MyTabs.create_component_id(session), MyTabs)
+ self._add_stuff = InstanceManager.get(session,
+ AddStuffMenu.create_component_id(session),
+ AddStuffMenu,
+ settings_manager=settings_manager,
+ tabs_manager=self._tabs)
+ self._repositories = InstanceManager.get(session,
+ Repositories.create_component_id(session),
+ Repositories,
+ settings_manager=settings_manager,
+ tabs_manager=self._tabs)
+ self._debugger = InstanceManager.get(session,
+ Debugger.create_component_id(session),
+ Debugger,
+ settings_manager=settings_manager,
+ tabs_manager=self._tabs)
+
+ def __ft__(self):
+ return Div(
+ Div(
+ Div(
+ self._add_stuff,
+ self._repositories,
+ self._debugger,
+ ),
+ Div(cls="dl-splitter", id=f"splitter_{self._id}"),
+ id=f"sidebar_{self._id}",
+ cls="dl-sidebar p-2",
+ name="sidebar"
+ ),
+ Div(
+ Label(
+ Input(type="checkbox",
+ onclick=f"document.getElementById('sidebar_{self._id}').classList.toggle('collapsed');"),
+ icon_panel_contract_regular,
+ icon_panel_expand_regular,
+ cls="swap",
+ ),
+
+ Div(self._tabs, id=f"page_{self._id}", name="page"),
+ cls='dl-main',
+ tabindex="0",
+ ),
+ cls="dl-container flex"
+ ), Script(f"bindDrawerLayout('{self._id}')")
+
+ @staticmethod
+ def create_component_id(session, suffix: str = ""):
+ return f"{DRAWER_LAYOUT_INSTANCE_ID}{session['user_id']}{suffix}"
diff --git a/src/components/drawerlayout/components/__init__.py b/src/components/drawerlayout/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/drawerlayout/constants.py b/src/components/drawerlayout/constants.py
new file mode 100644
index 0000000..88220bb
--- /dev/null
+++ b/src/components/drawerlayout/constants.py
@@ -0,0 +1,2 @@
+DRAWER_LAYOUT_INSTANCE_ID = "__DrawerLayout__"
+ROUTE_ROOT = "/pages"
diff --git a/src/components/dummy/__init__.py b/src/components/dummy/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/dummy/components/DummyComponent.py b/src/components/dummy/components/DummyComponent.py
new file mode 100644
index 0000000..faf134c
--- /dev/null
+++ b/src/components/dummy/components/DummyComponent.py
@@ -0,0 +1,31 @@
+from fasthtml.components import *
+
+from components.BaseComponent import BaseComponent
+
+
+class DummyComponent(BaseComponent):
+ def __init__(self, session, instance_id):
+ super().__init__(session, instance_id)
+
+ def __ft__(self):
+ return Div(
+ Input(id='my-drawer', type='checkbox', cls='drawer-toggle'),
+ Div(
+ Label('Open drawer', fr='my-drawer', cls='btn btn-primary drawer-button'),
+ cls='drawer-content'
+ ),
+ Div(
+ Label(fr='my-drawer', aria_label='close sidebar', cls='drawer-overlay'),
+ Ul(
+ Li(
+ A('Sidebar Item 1')
+ ),
+ Li(
+ A('Sidebar Item 2')
+ ),
+ cls='menu bg-base-200 text-base-content min-h-full w-80 p-4'
+ ),
+ cls='drawer-side'
+ ),
+ cls='drawer drawer-end'
+ )
diff --git a/src/components/dummy/components/__init__.py b/src/components/dummy/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/footer.py b/src/components/footer.py
new file mode 100644
index 0000000..58081ae
--- /dev/null
+++ b/src/components/footer.py
@@ -0,0 +1,14 @@
+from fasthtml.common import *
+
+import config
+
+
+def footer():
+ """Creates a consistent footer."""
+ return Footer(
+ Div(
+ P(f"© 2025 {config.APP_NAME}. Built with FastHTML."),
+ cls="px-4 py-2"
+ ),
+ cls="footer sm:footer-horizontal bg-neutral text-neutral-content"
+ )
diff --git a/src/components/form/FormApp.py b/src/components/form/FormApp.py
new file mode 100644
index 0000000..a449045
--- /dev/null
+++ b/src/components/form/FormApp.py
@@ -0,0 +1,17 @@
+import logging
+
+from fasthtml.fastapp import fast_app
+
+from components.form.constants import Routes
+from core.instance_manager import InstanceManager
+
+logger = logging.getLogger("FormApp")
+
+form_app, rt = fast_app()
+
+
+@rt(Routes.OnUpdate)
+def put(session, _id: str, data: dict):
+ logger.debug(f"Entering {Routes.OnUpdate} with args {_id=}, {data=}")
+ instance = InstanceManager.get(session, _id)
+ instance.update_state(data)
diff --git a/src/components/form/__init__.py b/src/components/form/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/form/components/MyForm.py b/src/components/form/components/MyForm.py
new file mode 100644
index 0000000..ce6af15
--- /dev/null
+++ b/src/components/form/components/MyForm.py
@@ -0,0 +1,123 @@
+import dataclasses
+import logging
+
+from fasthtml.components import *
+
+from components.BaseComponent import BaseComponent
+from components.form.constants import MY_FORM_INSTANCE_ID, Routes, ROUTE_ROOT
+from core.utils import get_unique_id
+
+logger = logging.getLogger("MyForm")
+
+
+@dataclasses.dataclass
+class FormField:
+ name: str
+ label: str
+ type: str
+
+
+class MyForm(BaseComponent):
+ def __init__(self, session: dict, _id: str,
+ title: str = None,
+ fields: list[FormField] = None,
+ state: dict = None, # to remember the values of the fields
+ submit: str = "Submit", # submit button
+ htmx_params: dict = None, # htmx parameters
+ extra_values: dict = None, # hx_vals parameters, but using python dict rather than javascript
+ success: str = None,
+ error: str = None
+ ):
+ super().__init__(session, _id)
+ self.title = title
+ self.fields = fields
+ self.state: dict = {} if state is None else state
+ self.submit = submit
+ self.htmx_params = htmx_params
+ self.extra_values = extra_values
+ self.success = success
+ self.error = error
+
+ self.on_dispose = None
+
+ def update_state(self, state):
+ self.state = state
+
+ def set_error(self, error_message):
+ self.error = error_message
+
+ def set_success(self, success_message):
+ self.success = success_message
+
+ def dispose(self):
+ logger.debug("Calling dispose")
+ if self.on_dispose:
+ self.on_dispose()
+
+ def __ft__(self):
+ message_alert = None
+ if self.error:
+ message_alert = Div(
+ P(self.error, cls="text-sm"),
+ cls="bg-error border border-red-400 text-red-700 px-4 py-3 rounded mb-4"
+ )
+ elif self.success:
+ message_alert = Div(
+ P(self.success, cls="text-sm"),
+ cls="bg-success border border-green-400 text-green-700 px-4 py-3 rounded mb-4"
+ )
+
+ return Div(
+ H1(self.title, cls="text-xl font-bold text-center mb-6"),
+
+ Div(
+ message_alert if message_alert else "",
+
+ Form(
+ Input(type="hidden", name="form_id", value=self._id),
+
+ *[self.mk_field(field) for field in self.fields],
+
+ Button(
+ self.submit,
+ hx_post=self.htmx_params.get("hx-post", None),
+ hx_target=self.htmx_params.get("hx-target", None),
+ hx_swap=self.htmx_params.get("hx-swap", None),
+ hx_vals=self.htmx_params.get("hx-vals", f"js:{{...{self.extra_values} }}" if self.extra_values else None),
+ cls="btn w-full font-bold py-2 px-4 rounded button-xs"
+ ),
+
+ # action=self.action,
+ # method="post",
+ cls="mb-6"
+ ),
+
+ id="focusable-div"
+ ),
+ cls="p-8 max-w-md mx-auto"
+ )
+
+ def mk_field(self, field: FormField):
+ return Div(
+ Label(field.label, cls="block text-sm font-medium text-gray-700 mb-1"),
+ Input(
+ type=field.type,
+ id=field.name,
+ name=field.name,
+ placeholder=field.label,
+ required=True,
+ value=self.state.get(field.name, None),
+
+ hx_put=f"{ROUTE_ROOT}{Routes.OnUpdate}",
+ hx_trigger="keyup changed delay:300ms",
+ hx_vals=f'{{"_id": "{self._id}"}}',
+
+ cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 input-sm"
+ ),
+ cls="mb-6"
+ ),
+
+ @staticmethod
+ def create_component_id(session):
+ prefix = f"{MY_FORM_INSTANCE_ID}{session['user_id']}"
+ return get_unique_id(prefix)
diff --git a/src/components/form/components/__init__.py b/src/components/form/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/form/constants.py b/src/components/form/constants.py
new file mode 100644
index 0000000..8555adb
--- /dev/null
+++ b/src/components/form/constants.py
@@ -0,0 +1,5 @@
+MY_FORM_INSTANCE_ID = "__MyForm__"
+ROUTE_ROOT = "/forms"
+
+class Routes:
+ OnUpdate = "/on-update"
\ No newline at end of file
diff --git a/src/components/header/__init__.py b/src/components/header/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/header/components/MyHeader.py b/src/components/header/components/MyHeader.py
new file mode 100644
index 0000000..a82e03a
--- /dev/null
+++ b/src/components/header/components/MyHeader.py
@@ -0,0 +1,63 @@
+from fasthtml.components import *
+
+import config
+from auth.auth_manager import AuthManager
+from components.BaseComponent import BaseComponent
+from components.header.constants import HEADER_INSTANCE_ID
+from components.login.constants import Routes as LoginRoutes, ROUTE_ROOT as LOGIN_ROUTE_ROOT
+from components.register.constants import Routes as RegisterRoutes, ROUTE_ROOT as REGISTER_ROUTE_ROOT
+from components.themecontroller.components.ThemeContoller import ThemeController
+from core.instance_manager import InstanceManager
+from core.settings_management import SettingsManager
+
+
+class MyHeader(BaseComponent):
+ def __init__(self, session, settings_manager: SettingsManager = None):
+ super().__init__(session, self.create_component_id(session))
+ self.settings_manager = settings_manager
+ self.theme_controller = ThemeController(self._session, self.settings_manager)
+ InstanceManager.register(self._session, self.theme_controller)
+
+ def __ft__(self):
+ # Get authentication status
+ is_authenticated = AuthManager.is_authenticated(self._session) if self._session else False
+ is_admin = AuthManager.is_admin(self._session) if self._session else False
+ username = self._session.get("username", "") if self._session else ""
+
+ auth_links = []
+ if is_authenticated:
+ auth_links.append(
+ Div(
+ # Username display
+ Span(f"Hello, {username}", cls="mr-4"),
+
+ # Logout link
+ A("Logout", href=LOGIN_ROUTE_ROOT + LoginRoutes.Logout, cls="btn mr-2"),
+ cls="flex items-center"
+ )
+ )
+ else:
+ auth_links.append(
+ Div(
+ A("Login", href=LOGIN_ROUTE_ROOT + LoginRoutes.Login, cls="btn btn-primary mr-2"),
+ A("Register", href=REGISTER_ROUTE_ROOT + RegisterRoutes.Register, cls="btn mr-1"),
+ cls="flex items-center"
+ )
+ )
+
+ return Div(
+ Div(
+ Div(A(config.APP_NAME, href="/", cls="btn btn-ghost text-xl"), cls="flex items-center ml-2", ),
+ Div(
+ *auth_links,
+ self.theme_controller,
+ cls="flex"
+ ),
+ cls="flex justify-between w-full"
+ ),
+ cls="navbar bg-base-300"
+ )
+
+ @staticmethod
+ def create_component_id(session):
+ return HEADER_INSTANCE_ID if session is None or 'user_id' not in session else f"{HEADER_INSTANCE_ID}_{session['user_id']}"
diff --git a/src/components/header/components/__init__.py b/src/components/header/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/header/constants.py b/src/components/header/constants.py
new file mode 100644
index 0000000..9a805ae
--- /dev/null
+++ b/src/components/header/constants.py
@@ -0,0 +1 @@
+HEADER_INSTANCE_ID = "__DrawerLayout__"
\ No newline at end of file
diff --git a/src/components/login/LoginApp.py b/src/components/login/LoginApp.py
new file mode 100644
index 0000000..ecefad9
--- /dev/null
+++ b/src/components/login/LoginApp.py
@@ -0,0 +1,63 @@
+import logging
+
+from fasthtml.fastapp import fast_app
+from starlette.responses import RedirectResponse
+
+from auth.auth_manager import AuthManager
+from auth.email_auth import EmailAuth
+from components.login.constants import Routes, LOGIN_INSTANCE_ID
+from components.page_layout_new import page_layout_new
+from core.instance_manager import InstanceManager
+
+logger = logging.getLogger("LoggingApp")
+
+login_app, rt = fast_app()
+
+
+@rt(Routes.Login)
+def get(error_message: str = None, success_message: str = None):
+ """Handler for the login page route."""
+ instance = InstanceManager.get(None, LOGIN_INSTANCE_ID)
+ return page_layout_new(None,
+ instance.settings_manager,
+ instance.login_page(error_message, success_message))
+
+
+@rt(Routes.Logout)
+def get(session):
+ """Handler for logout."""
+ # Clear session data
+ AuthManager.logout_user(session)
+
+ # Redirect to login page
+ return RedirectResponse('/login', status_code=303)
+
+
+@rt(Routes.LoginByEmail)
+def post(session, email: str, password: str):
+ """Handler for email login."""
+ # Authenticate user
+ success, message, user_data = EmailAuth.authenticate(
+ email=email,
+ password=password
+ )
+
+ instance = InstanceManager.get(None, LOGIN_INSTANCE_ID)
+
+ if not success:
+ return page_layout_new(
+ session,
+ instance.settings_manager,
+ instance.login_page(error_message=message),
+ )
+
+ # Log in user by setting session data
+ AuthManager.login_user(session, user_data)
+
+ # Make sure that the settings are created for this user
+ user_id = user_data["id"]
+ user_email = user_data["email"]
+ instance.init_user(user_id, user_email)
+
+ # Redirect to home page
+ return RedirectResponse('/', status_code=303)
diff --git a/src/components/login/__init__.py b/src/components/login/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/login/components/Login.py b/src/components/login/components/Login.py
new file mode 100644
index 0000000..f3d414a
--- /dev/null
+++ b/src/components/login/components/Login.py
@@ -0,0 +1,108 @@
+from fasthtml.components import *
+
+from components.login.constants import Routes, LOGIN_INSTANCE_ID, ROUTE_ROOT
+from core.settings_management import SettingsManager
+
+
+class Login:
+ def __init__(self, settings_manager: SettingsManager, error_message=None, success_message=None):
+ """
+ Create the login page.
+
+ Args:
+ error_message: Optional error message to display
+ success_message: Optional success message to display
+
+ Returns:
+ Components representing the login page
+ """
+ self._id = LOGIN_INSTANCE_ID
+ self.settings_manager = settings_manager
+ self.error_message = error_message
+ self.success_message = success_message
+
+ def login_page(self, success_message=None, error_message=None):
+ self.success_message = success_message
+ self.error_message = error_message
+ return self.__ft__()
+
+ def init_user(self, user_id: str, user_email: str):
+ return self.settings_manager.init_user(user_id, user_email)
+
+ def __ft__(self):
+ # Create alert for error or success message
+ message_alert = None
+ if self.error_message:
+ message_alert = Div(
+ P(self.error_message, cls="text-sm"),
+ cls="bg-error border border-red-400 text-red-700 px-4 py-3 rounded mb-4"
+ )
+ elif self.success_message:
+ message_alert = Div(
+ P(self.success_message, cls="text-sm"),
+ cls="bg-success border border-green-400 text-green-700 px-4 py-3 rounded mb-4"
+ )
+
+ return Div(
+ # Page title
+ H1("Sign In", cls="text-3xl font-bold text-center mb-6"),
+
+ # Login Form
+ Div(
+ # Message alert
+ message_alert if message_alert else "",
+
+ # Email login form
+ Form(
+ # Email field
+ Div(
+ Label("Email", For="email", cls="block text-sm font-medium text-gray-700 mb-1"),
+ Input(
+ type="email",
+ id="email",
+ name="email",
+ placeholder="you@example.com",
+ required=True,
+ cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
+ ),
+ cls="mb-4"
+ ),
+
+ # Password field
+ Div(
+ Label("Password", For="password", cls="block text-sm font-medium text-gray-700 mb-1"),
+ Input(
+ type="password",
+ id="password",
+ name="password",
+ placeholder="Your password",
+ required=True,
+ cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
+ ),
+ cls="mb-6"
+ ),
+
+ # Submit button
+ Button(
+ "Sign In",
+ type="submit",
+ cls="btn w-full font-bold py-2 px-4 rounded"
+ ),
+
+ action=ROUTE_ROOT + Routes.LoginByEmail,
+ method="post",
+ cls="mb-6"
+ ),
+
+ # Registration link
+ Div(
+ P(
+ "Don't have an account? ",
+ A("Register here", href="/register", cls="text-blue-600 hover:underline"),
+ cls="text-sm text-gray-600 text-center"
+ )
+ ),
+
+ cls="p-8 rounded-lg shadow-2xl max-w-md mx-auto"
+ )
+ )
diff --git a/src/components/login/components/__init__.py b/src/components/login/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/login/constants.py b/src/components/login/constants.py
new file mode 100644
index 0000000..5f85c7a
--- /dev/null
+++ b/src/components/login/constants.py
@@ -0,0 +1,7 @@
+LOGIN_INSTANCE_ID = "__login__"
+ROUTE_ROOT = "/authlogin"
+
+class Routes:
+ Login = "/login"
+ Logout = "/logout"
+ LoginByEmail = "/email/login"
\ No newline at end of file
diff --git a/src/components/page_layout_new.py b/src/components/page_layout_new.py
new file mode 100644
index 0000000..ec4b9b9
--- /dev/null
+++ b/src/components/page_layout_new.py
@@ -0,0 +1,32 @@
+from fasthtml.components import *
+
+import config
+from components.footer import footer
+from components.header.components.MyHeader import MyHeader
+
+
+def page_layout_new(session, settings_manager, content):
+ return Html(
+ Head(
+ Meta(charset="UTF-8"),
+ Meta(name="viewport", content="width=device-width, initial-scale=1.0"),
+ Link(href="https://cdn.jsdelivr.net/npm/daisyui@5", rel="stylesheet", type="text/css"),
+ Link(href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css", rel="stylesheet", type="text/css"),
+ Script(src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"),
+ ),
+ Body(
+ page_layout_lite(session, settings_manager, content),
+ )
+ )
+
+
+def page_layout_lite(session, settings_manager, content):
+ return (
+ Title(f"{config.APP_NAME}"),
+ Div(
+ MyHeader(session, settings_manager),
+ Main(content, cls="flex-grow"),
+ footer(),
+ cls="flex flex-col min-h-screen"
+ )
+ )
diff --git a/src/components/register/RegisterApp.py b/src/components/register/RegisterApp.py
new file mode 100644
index 0000000..0b3fcc9
--- /dev/null
+++ b/src/components/register/RegisterApp.py
@@ -0,0 +1,71 @@
+import logging
+
+from fasthtml.fastapp import fast_app
+
+from auth.email_auth import EmailAuth
+from components.login.constants import LOGIN_INSTANCE_ID
+from components.page_layout_new import page_layout_new
+from components.register.constants import Routes, REGISTER_INSTANCE_ID
+from core.instance_manager import InstanceManager
+
+logger = logging.getLogger("RegisterApp")
+
+register_app, rt = fast_app()
+
+
+@rt(Routes.Register)
+def get(error_message: str = None):
+ """Handler for the registration page route."""
+ instance = InstanceManager.get(None, REGISTER_INSTANCE_ID)
+ return page_layout_new(None,
+ instance.settings_manager,
+ instance.register_page(error_message))
+
+
+@rt(Routes.RegisterByEmail)
+def post(
+ session,
+ username: str,
+ email: str,
+ password: str,
+ confirm_password: str
+):
+ """Handler for email registration."""
+ instance = InstanceManager.get(None, REGISTER_INSTANCE_ID)
+
+ # Validate registration input
+ is_valid, error_message = EmailAuth.validate_registration(
+ username=username,
+ email=email,
+ password=password,
+ confirm_password=confirm_password
+ )
+
+ if not is_valid:
+ return page_layout_new(
+ session,
+ instance.settings_manager,
+ instance.register_page(error_message),
+ )
+
+ # Create user
+ success, message, user_id = EmailAuth.register_user(
+ username=username,
+ email=email,
+ password=password
+ )
+
+ if not success:
+ return page_layout_new(
+ session,
+ instance.settings_manager,
+ instance.register_page(message),
+ )
+
+ # Redirect to login with success message
+ login_instance = InstanceManager.get(session, LOGIN_INSTANCE_ID)
+ return page_layout_new(
+ session,
+ login_instance.settings_manager,
+ login_instance.login_page(success_message="Registration successful! Please sign in."),
+ )
diff --git a/src/components/register/__init__.py b/src/components/register/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/register/components/Register.py b/src/components/register/components/Register.py
new file mode 100644
index 0000000..0c055a9
--- /dev/null
+++ b/src/components/register/components/Register.py
@@ -0,0 +1,120 @@
+from fasthtml.components import *
+
+from components.register.constants import REGISTER_INSTANCE_ID, ROUTE_ROOT, Routes
+
+
+class Register:
+ def __init__(self, settings_manager, error_message: str = None):
+ self._id = REGISTER_INSTANCE_ID
+ self.settings_manager = settings_manager
+ self.error_message = error_message
+
+ def register_page(self, error_message: str):
+ self.error_message = error_message
+ return self.__ft__()
+
+ def __ft__(self):
+ """
+ Create the registration page.
+
+ Args:
+ error_message: Optional error message to display
+
+ Returns:
+ Components representing the registration page
+ """
+ # Create alert for error message
+ error_alert = None
+ if self.error_message:
+ error_alert = Div(
+ P(self.error_message, cls="text-sm"),
+ cls="bg-soft bg-error border border-red-400 text-red-700 px-4 py-3 rounded mb-4"
+ )
+
+ return Div(
+ # Page title
+ H1("Create an Account", cls="text-3xl font-bold text-center mb-6"),
+
+ # Registration Form
+ Div(
+ # Error alert
+ error_alert if error_alert else "",
+
+ Form(
+ # Username field
+ Div(
+ Label("Username", For="username", cls="block text-sm font-medium text-gray-700 mb-1"),
+ Input(
+ type="text",
+ id="username",
+ name="username",
+ placeholder="Choose a username",
+ required=True,
+ minlength=3,
+ maxlength=30,
+ pattern="[a-zA-Z0-9_-]+",
+ cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
+ ),
+ P("Only letters, numbers, underscores, and hyphens", cls="text-xs text-gray-500 mt-1"),
+ cls="mb-4"
+ ),
+
+ # Email field
+ Div(
+ Label("Email", For="email", cls="block text-sm font-medium text-gray-700 mb-1"),
+ Input(
+ type="email",
+ id="email",
+ name="email",
+ placeholder="you@example.com",
+ required=True,
+ cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
+ ),
+ cls="mb-4"
+ ),
+
+ # Password field
+ Div(
+ Label("Password", For="password", cls="block text-sm font-medium text-gray-700 mb-1"),
+ Input(
+ type="password",
+ id="password",
+ name="password",
+ placeholder="Create a password",
+ required=True,
+ minlength=8,
+ cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
+ ),
+ P("At least 8 characters with uppercase, lowercase, and number", cls="text-xs text-gray-500 mt-1"),
+ cls="mb-4"
+ ),
+
+ # Confirm password field
+ Div(
+ Label("Confirm Password", For="confirm_password", cls="block text-sm font-medium text-gray-700 mb-1"),
+ Input(
+ type="password",
+ id="confirm_password",
+ name="confirm_password",
+ placeholder="Confirm your password",
+ required=True,
+ cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
+ ),
+ cls="mb-6"
+ ),
+
+ # Submit button
+ Button(
+ "Create Account",
+ type="submit",
+ cls="btn w-full font-bold py-2 px-4 rounded"
+ ),
+
+ action=ROUTE_ROOT + Routes.RegisterByEmail,
+ method="post",
+ cls="mb-6"
+ ),
+
+ cls="p-8 rounded-lg shadow-2xl max-w-md mx-auto"
+ )
+ )
diff --git a/src/components/register/components/__init__.py b/src/components/register/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/register/constants.py b/src/components/register/constants.py
new file mode 100644
index 0000000..9fc8d40
--- /dev/null
+++ b/src/components/register/constants.py
@@ -0,0 +1,6 @@
+REGISTER_INSTANCE_ID = "__register__"
+ROUTE_ROOT = "/authregister"
+
+class Routes:
+ Register = "/register"
+ RegisterByEmail = "/email/register"
\ No newline at end of file
diff --git a/src/components/tabs/TabsApp.py b/src/components/tabs/TabsApp.py
new file mode 100644
index 0000000..c582ed2
--- /dev/null
+++ b/src/components/tabs/TabsApp.py
@@ -0,0 +1,22 @@
+import logging
+
+from fasthtml.fastapp import fast_app
+
+from components.tabs.constants import Routes
+from core.instance_manager import InstanceManager
+
+logger = logging.getLogger("TabsApp")
+
+tabs_app, rt = fast_app()
+
+@rt(Routes.SelectTab)
+def post(session, _id: str, tab_id: str):
+ instance = InstanceManager.get(session, _id)
+ instance.select_tab_by_id(tab_id)
+ return instance
+
+@rt(Routes.RemoveTab)
+def post(session, _id: str, tab_id: str):
+ instance = InstanceManager.get(session, _id)
+ instance.remove_tab(tab_id)
+ return instance
\ No newline at end of file
diff --git a/src/components/tabs/__init__.py b/src/components/tabs/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/tabs/assets/__init__.py b/src/components/tabs/assets/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/tabs/assets/tabs.css b/src/components/tabs/assets/tabs.css
new file mode 100644
index 0000000..34eeb8f
--- /dev/null
+++ b/src/components/tabs/assets/tabs.css
@@ -0,0 +1,45 @@
+.tabs {
+ background-color: var(--color-base-200);
+ color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
+ border-radius: .5rem;
+ width: 100%;
+ height: 100%;
+}
+
+.tabs-content {
+ display: block;
+ width: 100%;
+ height: 100%;
+ background-color: var(--color-base-100);
+ padding: 4px;
+}
+
+.tabs-tab {
+ cursor: pointer;
+ appearance: none;
+ text-align: center;
+ user-select: none;
+ font-size: .875rem;
+ display: flex;
+ margin: 4px 6px 0 6px;
+ padding: 0 6px;
+ align-items: center;
+ border-radius: .25rem;
+
+}
+
+.tabs-tab:hover {
+ color: var(--color-base-content); /* Change text color on hover */
+}
+
+.tabs-label {
+ max-width: 150px;
+}
+
+.tabs-active {
+ --depth: 1;
+ background-color: var(--color-base-100);
+ color: var(--color-base-content);
+ border-radius: .25rem;
+ box-shadow: 0 1px oklch(100% 0 0/calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);
+}
\ No newline at end of file
diff --git a/src/components/tabs/assets/tabs.js b/src/components/tabs/assets/tabs.js
new file mode 100644
index 0000000..e28f811
--- /dev/null
+++ b/src/components/tabs/assets/tabs.js
@@ -0,0 +1,3 @@
+function bindTabs(tabsId) {
+ bindTooltipsWithDelegation(tabsId)
+}
\ No newline at end of file
diff --git a/src/components/tabs/components/MyTabs.py b/src/components/tabs/components/MyTabs.py
new file mode 100644
index 0000000..a019462
--- /dev/null
+++ b/src/components/tabs/components/MyTabs.py
@@ -0,0 +1,192 @@
+import dataclasses
+import logging
+
+from fasthtml.components import *
+from fasthtml.xtend import Script
+
+from assets.icons import icon_dismiss_regular
+from components.BaseComponent import BaseComponent
+from components.tabs.constants import MY_TABS_INSTANCE_ID, Routes, ROUTE_ROOT
+from components_helpers import mk_ellipsis, mk_tooltip_container
+from core.instance_manager import InstanceManager
+from core.utils import get_unique_id
+
+logger = logging.getLogger("MyTabs")
+
+
+@dataclasses.dataclass
+class Tab:
+ id: str # unique id for the table
+ title: str # title to display
+ content: str #
+ key: str | tuple = None # another way to retrieve the tab, based on a key
+ icon: str = None
+ active: bool = False
+
+
+class MyTabs(BaseComponent):
+ def __init__(self, session: dict, _id: str):
+ super().__init__(session, _id)
+ self.tabs = [
+ ]
+ self.tabs_by_key = {}
+
+ def request_new_tab_id(self):
+ return get_unique_id(self._id)
+
+ def select_tab_by_id(self, tab_id):
+ """
+ Sets the active state of a tab given its unique identifier. This allows toggling
+ the currently active tab in a group of tabs. Only one tab is active at a time.
+
+ :param tab_id: A unique identifier for the tab to be activated. Must correspond
+ to the id of one of the tabs present in the `self.tabs` list.
+ :type tab_id: Any
+
+ :return: None
+ """
+ for tab in self.tabs:
+ tab.active = tab.id == tab_id
+
+ def select_tab_by_key(self, key):
+ """
+ Selects a tab using the specified key.
+
+ :param key: The unique key identifying the tab to be selected.
+ :type key: Any
+ :return: None
+ :rtype: NoneType
+ """
+ if key in self.tabs_by_key:
+ self.select_tab_by_id(self.tabs_by_key[key].id)
+
+ def remove_tab(self, tab_id):
+ """
+ Removes a tab with the specified ID from the current list of tabs.
+
+ :param tab_id: The unique identifier of the tab to be removed.
+ :type tab_id: Any
+ :return: None
+ """
+ logger.debug(f"'remove_tab' {tab_id=}, {self.tabs=}")
+ to_remove = next(filter(lambda t: t.id == tab_id, self.tabs), None)
+ if to_remove is None:
+ logger.warning(f" No tab found with id {tab_id=}")
+ return
+
+ # dispose the content if required
+ if hasattr(to_remove.content, "dispose") and callable(to_remove.content.dispose):
+ to_remove.content.dispose()
+ InstanceManager.remove(self._session, to_remove.content)
+
+ # remove the tab
+ self.tabs = [tab for tab in self.tabs if tab.id != tab_id]
+
+ # clean the tab by key
+ if to_remove.key in self.tabs_by_key:
+ del self.tabs_by_key[to_remove.key]
+
+ # Check if there is no active tab; if so, call select_tab for the first tab (if available)
+ if self.tabs and not any(tab.active for tab in self.tabs):
+ self.select_tab_by_id(self.tabs[0].id)
+
+ def add_tab(self, title, content, key: str | tuple = None, tab_id: str = None, icon=None):
+ """
+ Adds a new tab with the specified title, content, and optional icon, then selects
+ the newly created tab by its unique identifier.
+
+ :param title: The title of the new tab.
+ :param content: The content to be displayed within the tab.
+ :param key:
+ :param tab_id:
+ :param icon: An optional icon to represent the tab visually.
+ :return: The unique identifier of the newly created tab.
+ """
+ logger.debug(f"'add_tab' {title=}, {content=}, {key=}")
+
+ if key in self.tabs_by_key:
+ # deal with potentially an already known entry
+ older_tab = self.tabs_by_key[key]
+ if older_tab.content != content:
+ self.remove_tab(older_tab.id)
+ id_to_use = older_tab.id
+ new_tab = Tab(id_to_use, title, content, key=key, icon=icon)
+ self.tabs.append(new_tab)
+ self.tabs_by_key[key] = new_tab
+
+ self.select_tab_by_key(key)
+ return self.tabs_by_key[key].id
+
+ # else create a new tab
+ id_to_use = tab_id or get_unique_id(self._id)
+ new_tab = Tab(id_to_use, title, content, key=key, icon=icon)
+ self.tabs.append(new_tab)
+ if key is not None:
+ self.tabs_by_key[key] = new_tab
+
+ self.select_tab_by_id(new_tab.id)
+ return new_tab.id
+
+ def set_tab_content(self, tab_id, content, title=None, key: str | tuple = None, active=None):
+ logger.debug(f"'set_tab_content' {tab_id=}, {content=}, {active=}")
+ to_modify = next(filter(lambda t: t.id == tab_id, self.tabs), None)
+ if to_modify is None:
+ logger.warning(f" No tab found with id {tab_id=}")
+ return
+
+ to_modify.content = content
+ if to_modify.key is not None:
+ del self.tabs_by_key[to_modify.key]
+
+ if title is not None:
+ to_modify.title = title
+
+ if key is not None:
+ self.tabs_by_key[key] = to_modify
+
+ if active is not None:
+ to_modify.active = active
+
+ def refresh(self):
+ return self.render(oob=True)
+
+ def __ft__(self):
+ return mk_tooltip_container(self._id), self.render(), Script(f"bindTabs('{self._id}')")
+
+ def render(self, oob=False):
+ if not self.tabs:
+ return Div(id=self._id, hx_swap_oob="true" if oob else None)
+
+ active_content = self.get_active_tab_content()
+ if hasattr(active_content, "on_htmx_after_settle"):
+ extra_params = {"hx-on::after-settle": active_content.on_htmx_after_settle()}
+ else:
+ extra_params = {}
+
+ return Div(
+ *[self._mk_tab(tab) for tab in self.tabs], # headers
+ Div(active_content, cls="tabs-content"),
+ cls="tabs",
+ id=self._id,
+ hx_swap_oob="true" if oob else None,
+ **extra_params,
+ )
+
+ def _mk_tab(self, tab: Tab):
+ return Span(
+ Label(mk_ellipsis(tab.title), hx_post=f"{ROUTE_ROOT}{Routes.SelectTab}", cls="tabs-label truncate"),
+ Div(icon_dismiss_regular, cls="icon-16 ml-2", hx_post=f"{ROUTE_ROOT}{Routes.RemoveTab}"),
+ cls=f"tabs-tab {'tabs-active' if tab.active else ''}",
+ hx_vals=f'{{"_id": "{self._id}", "tab_id":"{tab.id}"}}',
+ hx_target=f"#{self._id}",
+ hx_swap="outerHTML",
+ )
+
+ def get_active_tab_content(self):
+ active_tab = next(filter(lambda t: t.active, self.tabs), None)
+ return active_tab.content if active_tab else None
+
+ @staticmethod
+ def create_component_id(session):
+ prefix = f"{MY_TABS_INSTANCE_ID}{session['user_id']}"
+ return get_unique_id(prefix)
diff --git a/src/components/tabs/components/__init__.py b/src/components/tabs/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/tabs/constants.py b/src/components/tabs/constants.py
new file mode 100644
index 0000000..3077dd8
--- /dev/null
+++ b/src/components/tabs/constants.py
@@ -0,0 +1,8 @@
+MY_TABS_INSTANCE_ID = "__MyTabs__"
+ROUTE_ROOT = "/tabs"
+
+class Routes:
+ SelectTab = "/select"
+ AddTab = "/add"
+ RemoveTab = "/remove"
+ MoveTab = "/move"
\ No newline at end of file
diff --git a/src/components/themecontroller/ThemeControllerApp.py b/src/components/themecontroller/ThemeControllerApp.py
new file mode 100644
index 0000000..e55fadc
--- /dev/null
+++ b/src/components/themecontroller/ThemeControllerApp.py
@@ -0,0 +1,17 @@
+import logging
+
+from fasthtml.fastapp import fast_app
+
+from components.themecontroller.constants import Routes
+from core.instance_manager import InstanceManager
+
+logger = logging.getLogger("ThemeControllerApp")
+
+theme_controller_app, rt = fast_app()
+
+
+@rt(Routes.ChangeTheme)
+def post(session, _id: str, theme: str):
+ logger.debug(f"Entering {Routes.ChangeTheme} with args {session=}, {theme=}")
+ instance = InstanceManager.get(session, _id)
+ instance.change_theme(theme)
diff --git a/src/components/themecontroller/__init__.py b/src/components/themecontroller/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/themecontroller/components/ThemeContoller.py b/src/components/themecontroller/components/ThemeContoller.py
new file mode 100644
index 0000000..e399207
--- /dev/null
+++ b/src/components/themecontroller/components/ThemeContoller.py
@@ -0,0 +1,95 @@
+import logging
+import uuid
+
+from fasthtml.components import *
+from fasthtml.svg import *
+
+from components.themecontroller.constants import ROUTE_ROOT, Routes
+from components.themecontroller.settings import THEME_CONTROLLER_SETTINGS_ENTRY, ThemeControllerSettings
+from core.settings_management import SettingsManager
+
+logger = logging.getLogger("ThemeController")
+
+
+class ThemeController:
+ def __init__(self, session, settings_manager: SettingsManager, /, _id=None):
+ self._id = _id or uuid.uuid4().hex
+ self.session = session
+ self.settings_manager = settings_manager
+
+ self.settings = self.settings_manager.get(session,
+ THEME_CONTROLLER_SETTINGS_ENTRY,
+ default=ThemeControllerSettings())
+
+ def __ft__(self):
+ return Div(
+ Div(
+ Div(
+ Div(cls='bg-base-content size-1 rounded-full'),
+ Div(cls='bg-primary size-1 rounded-full'),
+ Div(cls='bg-secondary size-1 rounded-full'),
+ Div(cls='bg-accent size-1 rounded-full'),
+ cls='bg-base-100 border-base-content/10 grid shrink-0 grid-cols-2 gap-0.5 rounded-md border p-1'
+ ),
+ Svg(
+ Path(d='M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z'),
+ width='12px',
+ height='12px',
+ viewbox='0 0 2048 2048',
+ cls='mt-px hidden h-2 w-2 fill-current opacity-60 sm:inline-block'
+ ),
+ tabindex='0',
+ role='button',
+ cls='btn m-1'
+ ),
+ Ul(
+ Li(
+ Input(type='radio', name='theme', aria_label='Default', value='default',
+ cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
+ checked=self.settings.theme is None or self.settings.theme == 'default',
+ hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
+ hx_vals=f'{{"_id": "{self._id}"}}',
+ )
+ ),
+ Li(
+ Input(type='radio', name='theme', aria_label='Dark', value='dark',
+ cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
+ checked=self.settings.theme == 'dark',
+ hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
+ hx_vals=f'{{"_id": "{self._id}"}}',
+ )
+ ),
+ Li(
+ Input(type='radio', name='theme', aria_label='Light', value='light',
+ cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
+ checked=self.settings.theme == 'light',
+ hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
+ hx_vals=f'{{"_id": "{self._id}"}}',
+ )
+ ),
+ Li(
+ Input(type='radio', name='theme', aria_label='Cupcake', value='cupcake',
+ cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
+ checked=self.settings.theme == 'cupcake',
+ hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
+ hx_vals=f'{{"_id": "{self._id}"}}',
+ )
+ ),
+ Li(
+ Input(type='radio', name='theme', aria_label='Lofi', value='lofi',
+ cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
+ checked=self.settings.theme == 'lofi',
+ hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
+ hx_vals=f'{{"_id": "{self._id}"}}',
+ )
+ ),
+ tabindex='0',
+ cls='dropdown-content bg-base-200 rounded-box z-1 shadow-2xl',
+ ),
+ cls='dropdown dropdown-end block'
+ )
+
+ def change_theme(self, theme):
+ logger.debug(f"change_theme - Changing theme to '{theme}'.")
+ self.settings.theme = theme
+ self.settings_manager.put(self.session, THEME_CONTROLLER_SETTINGS_ENTRY, self.settings)
diff --git a/src/components/themecontroller/components/__init__.py b/src/components/themecontroller/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/themecontroller/constants.py b/src/components/themecontroller/constants.py
new file mode 100644
index 0000000..88de803
--- /dev/null
+++ b/src/components/themecontroller/constants.py
@@ -0,0 +1,4 @@
+ROUTE_ROOT = "/settings"
+
+class Routes:
+ ChangeTheme = "/change_theme" # request the filtering in the grid
\ No newline at end of file
diff --git a/src/components/themecontroller/settings.py b/src/components/themecontroller/settings.py
new file mode 100644
index 0000000..cce4ebc
--- /dev/null
+++ b/src/components/themecontroller/settings.py
@@ -0,0 +1,12 @@
+import dataclasses
+
+from core.settings_objects import BaseSettingObj
+
+THEME_CONTROLLER_SETTINGS_ENTRY = "ThemeControllerSettings"
+
+
+@dataclasses.dataclass
+class ThemeControllerSettings(BaseSettingObj):
+ __ENTRY_NAME__ = THEME_CONTROLLER_SETTINGS_ENTRY
+
+ theme: str = None
diff --git a/src/components/useradmin/UserAdminApp.py b/src/components/useradmin/UserAdminApp.py
new file mode 100644
index 0000000..f6d28aa
--- /dev/null
+++ b/src/components/useradmin/UserAdminApp.py
@@ -0,0 +1,128 @@
+@app.get("/admin")
+def admin(session):
+ """Handler for the admin dashboard page route."""
+ # Check admin authentication
+ auth_redirect = require_auth(session, admin_required=True)
+ if auth_redirect:
+ return auth_redirect
+
+ return page_layout_new(
+ session,
+ settings_manager,
+ content=admin_dashboard(),
+ )
+
+
+@app.get("/admin/users")
+def admin_users(session, page: int = 1, error_message: str = None, success_message: str = None):
+ """Handler for the admin users page route."""
+ # Check admin authentication
+ auth_redirect = require_auth(session, admin_required=True)
+ if auth_redirect:
+ return auth_redirect
+
+ return page_layout(
+ title=f"User Management - {config.APP_NAME}",
+ content=admin_users_page(page, error_message, success_message),
+ current_page="/admin",
+ session=session
+ )
+
+
+@app.post("/admin/users/{user_id:int}/make-admin")
+def make_admin(user_id: int, session):
+ """Handler for making a user an admin."""
+ # Check admin authentication
+ auth_redirect = require_auth(session, admin_required=True)
+ if auth_redirect:
+ return auth_redirect
+
+ # Set admin status
+ success = UserDAO.set_admin_status(user_id, True)
+
+ if success:
+ return page_layout(
+ title=f"User Management - {config.APP_NAME}",
+ content=admin_users_page(success_message="User successfully made admin"),
+ current_page="/admin",
+ session=session
+ )
+ else:
+ return page_layout(
+ title=f"User Management - {config.APP_NAME}",
+ content=admin_users_page(error_message="Failed to update user status"),
+ current_page="/admin",
+ session=session
+ )
+
+
+@app.post("/admin/users/{user_id:int}/remove-admin")
+def remove_admin(user_id: int, session):
+ """Handler for removing admin status from a user."""
+ # Check admin authentication
+ auth_redirect = require_auth(session, admin_required=True)
+ if auth_redirect:
+ return auth_redirect
+
+ # Prevent removing admin status from the current user
+ if session.get("user_id") == user_id:
+ return page_layout(
+ title=f"User Management - {config.APP_NAME}",
+ content=admin_users_page(error_message="You cannot remove your own admin status"),
+ current_page="/admin",
+ session=session
+ )
+
+ # Set admin status
+ success = UserDAO.set_admin_status(user_id, False)
+
+ if success:
+ return page_layout(
+ title=f"User Management - {config.APP_NAME}",
+ content=admin_users_page(success_message="Admin status successfully removed"),
+ current_page="/admin",
+ session=session
+ )
+ else:
+ return page_layout(
+ title=f"User Management - {config.APP_NAME}",
+ content=admin_users_page(error_message="Failed to update user status"),
+ current_page="/admin",
+ session=session
+ )
+
+
+@app.post("/admin/users/{user_id:int}/delete")
+def admin_delete_user(user_id: int, session):
+ """Handler for deleting a user."""
+ # Check admin authentication
+ auth_redirect = require_auth(session, admin_required=True)
+ if auth_redirect:
+ return auth_redirect
+
+ # Prevent deleting the current user
+ if session.get("user_id") == user_id:
+ return page_layout(
+ title=f"User Management - {config.APP_NAME}",
+ content=admin_users_page(error_message="You cannot delete your own account"),
+ current_page="/admin",
+ session=session
+ )
+
+ # Delete user
+ success = UserDAO.delete_user(user_id)
+
+ if success:
+ return page_layout(
+ title=f"User Management - {config.APP_NAME}",
+ content=admin_users_page(success_message="User successfully deleted"),
+ current_page="/admin",
+ session=session
+ )
+ else:
+ return page_layout(
+ title=f"User Management - {config.APP_NAME}",
+ content=admin_users_page(error_message="Failed to delete user"),
+ current_page="/admin",
+ session=session
+ )
diff --git a/src/components/useradmin/__init__.py b/src/components/useradmin/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components_helpers.py b/src/components_helpers.py
new file mode 100644
index 0000000..7325688
--- /dev/null
+++ b/src/components_helpers.py
@@ -0,0 +1,50 @@
+from fasthtml.components import *
+
+from core.utils import merge_classes
+
+
+def mk_icon(icon, size=20, can_select=True, cls='', **kwargs):
+ merged_cls = merge_classes(f"icon-{size}",
+ 'icon-btn' if can_select else '',
+ cls,
+ kwargs)
+ return Div(icon, cls=merged_cls, **kwargs)
+
+
+def mk_ellipsis(txt: str, cls='', **kwargs):
+ merged_cls = merge_classes("truncate",
+ cls,
+ kwargs)
+ return Div(txt, cls=merged_cls, data_tooltip=txt, **kwargs)
+
+
+def mk_tooltip_container(component_id):
+ return Div(id=f"tt_{component_id}", style="position: fixed; z-index: 1000;", cls="mmt-tooltip-container"),
+
+
+def mk_dialog_buttons(ok_title: str = "OK", cancel_title: str = "Cancel", on_ok: dict = None, on_cancel: dict = None):
+ if on_ok is None:
+ on_ok = {}
+ if on_cancel is None:
+ on_cancel = {}
+
+ return Div(
+ Div(
+ Button(ok_title, cls="btn btn-primary btn-sm mr-2", **on_ok),
+ Button(cancel_title, cls="btn btn-ghost btn-sm", **on_cancel),
+ cls="flex justify-end"
+ ),
+ cls="flex justify-end w-full"
+ )
+
+
+def mk_select_option(option: str, value=None, selected_value: str = None, selected=False, enabled=True):
+ attrs = {}
+ if value is not None:
+ attrs["value"] = value
+ if selected_value == option or selected is True:
+ attrs["selected"] = True
+ if not enabled:
+ attrs["disabled"] = True
+
+ return Option(option, **attrs)
diff --git a/src/config.py b/src/config.py
new file mode 100644
index 0000000..423c1a1
--- /dev/null
+++ b/src/config.py
@@ -0,0 +1,43 @@
+import logging
+import os
+
+from dotenv import load_dotenv
+
+logger = logging.getLogger("config")
+
+# Load environment variables from .env file
+load_dotenv()
+
+# # API configuration
+# OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
+# OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
+#
+# # Default model to use
+# DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "openai/gpt-3.5-turbo")
+
+# Database settings
+DB_PATH = os.getenv("DB_PATH", "tools.db")
+logger.info(f"{DB_PATH=}")
+
+# Authentication settings
+SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
+
+# # GitHub OAuth settings
+# GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
+# GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET")
+# GITHUB_REDIRECT_URI = os.getenv("GITHUB_REDIRECT_URI", "/auth/github/callback")
+
+# Admin user (created on first run if provided)
+ADMIN_EMAIL = os.getenv("ADMIN_EMAIL")
+ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
+logger.info(f"{ADMIN_EMAIL=}")
+
+# Session expiration (in seconds)
+SESSION_EXPIRY = int(os.getenv("SESSION_EXPIRY", "604800")) # 7 days default
+logger.info(f"{SESSION_EXPIRY=}")
+
+# Application settings
+DEBUG = os.getenv("DEBUG", "True").lower() == "true"
+logger.info(f"{DEBUG=}")
+
+APP_NAME = "My Managing Tools"
diff --git a/src/constants.py b/src/constants.py
new file mode 100644
index 0000000..160e6b6
--- /dev/null
+++ b/src/constants.py
@@ -0,0 +1,3 @@
+class Routes:
+ Root = "/"
+ Logout = "/logout"
diff --git a/src/core/__init__.py b/src/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/core/dbengine.py b/src/core/dbengine.py
new file mode 100644
index 0000000..2ea5337
--- /dev/null
+++ b/src/core/dbengine.py
@@ -0,0 +1,335 @@
+import datetime
+import hashlib
+import io
+import json
+import logging
+import os
+import pickle
+from threading import RLock
+
+from core.serializer import Serializer, DebugSerializer
+from core.utils import get_stream_digest
+
+TYPE_KEY = "__type__"
+TAG_PARENT = "__parent__"
+TAG_USER = "__user_id__"
+TAG_DATE = "__date__"
+BUFFER_SIZE = 4096
+FAKE_USER_ID = "FakeUserId"
+
+logger = logging.getLogger(__name__)
+
+
+class DbException(Exception):
+ pass
+
+
+class RefHelper:
+ def __init__(self, get_obj_path):
+ self.get_obj_path = get_obj_path
+
+ def save_ref(self, obj):
+ """
+
+ :param obj:
+ :return:
+ """
+ buffer = io.BytesIO()
+ pickler = pickle.Pickler(buffer)
+ pickler.dump(obj)
+
+ digest = get_stream_digest(buffer)
+
+ target_path = self.get_obj_path(digest)
+ if not os.path.exists(os.path.dirname(target_path)):
+ os.makedirs(os.path.dirname(target_path))
+
+ buffer.seek(0)
+ with open(self.get_obj_path(digest), "wb") as file:
+ while chunk := buffer.read(BUFFER_SIZE):
+ file.write(chunk)
+
+ logger.debug(f"Saved object type '{type(obj).__name__}' with digest {digest}")
+ return digest
+
+ def load_ref(self, digest):
+ """
+
+ :param digest:
+ :return:
+ """
+ with open(self.get_obj_path(digest), 'rb') as file:
+ return pickle.load(file)
+
+
+class DbEngine:
+ """
+ Personal implementation of DB engine
+ Inspire by the way git manage its files
+ Designed to keep history of the modifications
+ """
+ ObjectsFolder = "objects" # group objects in the same folder
+ HeadFile = "head" # used to keep track the latest version of all entries
+
+ def __init__(self, root: str = None):
+ self.root = root or ".mytools_db"
+ self.serializer = Serializer(RefHelper(self._get_obj_path))
+ self.debug_serializer = DebugSerializer(RefHelper(self.debug_load))
+ self.lock = RLock()
+
+ def is_initialized(self):
+ """
+
+ :return:
+ """
+ return os.path.exists(self.root)
+
+ def init(self):
+ """
+ Make sure that the DbEngine is properly initialized
+ :return:
+ """
+ if not os.path.exists(self.root):
+ logger.debug(f"Creating root folder in {os.path.abspath(self.root)}.")
+ os.mkdir(self.root)
+
+ def save(self, user_id: str, entry: str, obj: object) -> str:
+ """
+ Save a snapshot of an entry
+ :param user_id:
+ :param entry:
+ :param obj: snapshot to save
+ :return:
+ """
+ with self.lock:
+ logger.info(f"Saving {user_id=}, {entry=}, {obj=}")
+ # prepare the data
+ as_dict = self._serialize(obj)
+ as_dict[TAG_PARENT] = [self._get_entry_digest(entry)]
+ as_dict[TAG_USER] = user_id
+ as_dict[TAG_DATE] = datetime.datetime.now().strftime('%Y%m%d %H:%M:%S %z')
+
+ # transform into a stream
+ as_str = json.dumps(as_dict, sort_keys=True, indent=4)
+ logger.debug(f"Serialized object : {as_str}")
+ byte_stream = as_str.encode("utf-8")
+
+ # compute the digest to know where to store it
+ digest = hashlib.sha256(byte_stream).hexdigest()
+
+ target_path = self._get_obj_path(digest)
+ if os.path.exists(target_path):
+ # the same object is already saved. Noting to do
+ return digest
+
+ # save the new value
+ if not os.path.exists(os.path.dirname(target_path)):
+ os.makedirs(os.path.dirname(target_path))
+ with open(target_path, "wb") as file:
+ file.write(byte_stream)
+
+ # update the head to remember where is the latest entry
+ self._update_head(entry, digest)
+ logger.debug(f"New head for entry '{entry}' is {digest}")
+ return digest
+
+ def load(self, user_id: str, entry, digest=None):
+ """
+ Loads a snapshot
+ :param user_id:
+ :param entry:
+ :param digest:
+ :return:
+ """
+ with self.lock:
+ logger.info(f"Loading {user_id=}, {entry=}, {digest=}")
+
+ digest_to_use = digest or self._get_entry_digest(entry)
+ logger.debug(f"Using digest {digest_to_use}.")
+
+ if digest_to_use is None:
+ raise DbException(entry)
+
+ target_file = self._get_obj_path(digest_to_use)
+ with open(target_file, 'r', encoding='utf-8') as file:
+ as_dict = json.load(file)
+
+ return self._deserialize(as_dict)
+
+ def put(self, user_id: str, entry, key: str, value: object):
+ """
+ Save a specific record.
+ This will create a new snapshot is the record is new or different
+
+ You should not mix the usage of put_many() and save() as it's two different way to manage the db
+ :param user_id:
+ :param entry:
+ :param key:
+ :param value:
+ :return:
+ """
+ with self.lock:
+ logger.info(f"Adding {user_id=}, {entry=}, {key=}, {value=}")
+ try:
+ entry_content = self.load(user_id, entry)
+ except DbException:
+ entry_content = {}
+
+ # Do not save if the entry is the same
+ if key in entry_content:
+ old_value = entry_content[key]
+ if old_value == value:
+ return False
+
+ entry_content[key] = value
+ self.save(user_id, entry, entry_content)
+ return True
+
+ def put_many(self, user_id: str, entry, items: list):
+ """
+ Save a list of item as one single snapshot
+ A new snapshot will not be created if all the items already exist
+
+ You should not mix the usage of put_many() and save() as it's two different way to manage the db
+ :param user_id:
+ :param entry:
+ :param items:
+ :return:
+ """
+ with self.lock:
+ logger.info(f"Adding many {user_id=}, {entry=}, {items=}")
+ try:
+ entry_content = self.load(user_id, entry)
+ except DbException:
+ entry_content = {}
+
+ is_dirty = False
+ for item in items:
+ key = item.get_key()
+ if key in entry_content and entry_content[key] == item:
+ continue
+ else:
+ entry_content[key] = item
+ is_dirty = True
+
+ if is_dirty:
+ self.save(user_id, entry, entry_content)
+ return True
+
+ return False
+
+ def exists(self, entry: str):
+ """
+ Tells if an entry exist
+ :param user_id:
+ :param entry:
+ :return:
+ """
+ with self.lock:
+ return self._get_entry_digest(entry) is not None
+
+ def get(self, user_id: str, entry: str, key: str | None = None, digest=None):
+ """
+ Retrieve an item from the snapshot
+ :param user_id:
+ :param entry:
+ :param key:
+ :param digest:
+ :return:
+ """
+ with self.lock:
+ logger.info(f"Getting {user_id=}, {entry=}, {key=}, {digest=}")
+ entry_content = self.load(user_id, entry, digest)
+
+ if key is None:
+ # return all items as list
+ return [v for k, v in entry_content.items() if not k.startswith("__")]
+
+ return entry_content[key]
+
+ def debug_head(self):
+ with self.lock:
+ head_path = os.path.join(self.root, self.HeadFile)
+ # load
+ try:
+ with open(head_path, 'r') as file:
+ head = json.load(file)
+ except FileNotFoundError:
+ head = {}
+
+ return head
+
+ def debug_load(self, digest):
+ with self.lock:
+ target_file = self._get_obj_path(digest)
+ with open(target_file, 'r', encoding='utf-8') as file:
+ as_dict = json.load(file)
+
+ return self.debug_serializer.deserialize(as_dict)
+
+ def _serialize(self, obj):
+ """
+ Just call the serializer
+ :param obj:
+ :return:
+ """
+ # serializer = Serializer(RefHelper(self._get_obj_path))
+ use_refs = getattr(obj, "use_refs")() if hasattr(obj, "use_refs") else None
+ return self.serializer.serialize(obj, use_refs)
+
+ def _deserialize(self, as_dict):
+ return self.serializer.deserialize(as_dict)
+
+ def _update_head(self, entry, digest):
+ """
+ Actually dumps the snapshot in file system
+ :param entry:
+ :param digest:
+ :return:
+ """
+ head_path = os.path.join(self.root, self.HeadFile)
+ # load
+ try:
+ with open(head_path, 'r') as file:
+ head = json.load(file)
+ except FileNotFoundError:
+ head = {}
+
+ # update
+ head[entry] = digest
+
+ # and save
+ with open(head_path, 'w') as file:
+ json.dump(head, file)
+
+ def _get_entry_digest(self, entry):
+ """
+ Search for the latest digest, for a given entry
+ :param entry:
+ :return:
+ """
+ head_path = os.path.join(self.root, self.HeadFile)
+ try:
+ with open(head_path, 'r') as file:
+ head = json.load(file)
+ return head[str(entry)]
+
+ except FileNotFoundError:
+ return None
+ except KeyError:
+ return None
+
+ def _get_head_path(self):
+ """
+ Location of the Head file
+ :return:
+ """
+ return os.path.join(self.root, self.HeadFile)
+
+ def _get_obj_path(self, digest):
+ """
+ Location of objects
+ :param digest:
+ :return:
+ """
+ return os.path.join(self.root, "objects", digest[:24], digest)
diff --git a/src/core/handlers.py b/src/core/handlers.py
new file mode 100644
index 0000000..d63a2be
--- /dev/null
+++ b/src/core/handlers.py
@@ -0,0 +1,59 @@
+# I delegate the complexity of some data type within specific handlers
+
+import datetime
+
+from core.utils import has_tag
+
+TAG_SPECIAL = "__special__"
+
+
+class BaseHandler:
+ def is_eligible_for(self, obj):
+ pass
+
+ def tag(self):
+ pass
+
+ def serialize(self, obj) -> dict:
+ pass
+
+ def deserialize(self, data: dict) -> object:
+ pass
+
+
+class DateHandler(BaseHandler):
+ def is_eligible_for(self, obj):
+ return isinstance(obj, datetime.date)
+
+ def tag(self):
+ return "Date"
+
+ def serialize(self, obj):
+ return {
+ TAG_SPECIAL: self.tag(),
+ "year": obj.year,
+ "month": obj.month,
+ "day": obj.day,
+ }
+
+ def deserialize(self, data: dict) -> object:
+ return datetime.date(year=data["year"], month=data["month"], day=data["day"])
+
+
+class Handlers:
+
+ def __init__(self, handlers_):
+ self.handlers = handlers_
+
+ def get_handler(self, obj):
+ if has_tag(obj, TAG_SPECIAL):
+ return [h for h in self.handlers if h.tag() == obj[TAG_SPECIAL]][0]
+
+ for h in self.handlers:
+ if h.is_eligible_for(obj):
+ return h
+
+ return None
+
+
+handlers = Handlers([DateHandler()])
diff --git a/src/core/instance_manager.py b/src/core/instance_manager.py
new file mode 100644
index 0000000..6334deb
--- /dev/null
+++ b/src/core/instance_manager.py
@@ -0,0 +1,108 @@
+import logging
+
+from components.BaseComponent import BaseComponent
+
+SESSION_ID_KEY = "user_id"
+NO_SESSION = "__NO_SESSION__"
+NOT_LOGGED = "__NOT_LOGGED__"
+
+logger = logging.getLogger("InstanceManager")
+
+
+def debug_session(session):
+ if session is None:
+ return f"session={NO_SESSION}"
+ else:
+ return f"session={InstanceManager.get_session_id(session)}"
+
+class InstanceManager:
+ _instances = {}
+
+ @staticmethod
+ def get(session: dict | None, instance_id: str, instance_type: type = None, **kwargs):
+ """
+ Retrieves an instance from the InstanceManager or creates a new one if it does not exist,
+ using the provided session, instance_id, and instance_type. If the instance already exists
+ in the InstanceManager, it will be returned directly. If the instance does not exist and
+ the instance_type is provided, a new instance will be created based on the instance_type.
+
+ :param session: The session context associated with the instance. Can be None.
+ :type session: dict or None
+ :param instance_id: The unique identifier for the instance to retrieve or create.
+ :type instance_id: str
+ :param instance_type: The class type for creating a new instance if it does not already
+ exist. Defaults to None.
+ :type instance_type: type
+ :param kwargs: Additional keyword arguments to initialize a new instance if it is created.
+ :return: The existing or newly created instance for the given session and instance_id.
+ """
+ logger.debug(f"'get' session={InstanceManager.get_session_id(session)}, {instance_id=}")
+
+ key = (InstanceManager.get_session_id(session), instance_id)
+
+ if key not in InstanceManager._instances and instance_type is not None:
+ new_instance = instance_type(session, instance_id, **kwargs) \
+ if issubclass(instance_type, BaseComponent) \
+ else instance_type(instance_id, **kwargs)
+ InstanceManager._instances[key] = new_instance
+
+ return InstanceManager._instances[key]
+
+ @staticmethod
+ def register(session: dict | None, instance, instance_id: str = None):
+ """
+ Register an instance with the given session_id and instance_id.
+ If instance_id is None, attempt to fetch it from the _id attribute of the instance.
+ If the instance has no _id attribute and instance_id is None, raise a ValueError.
+ If session_id is None, it is allowed.
+
+ Args:
+ session (dict): The current session.
+ instance: The instance to be registered.
+ instance_id (str): The instance ID.
+
+ Raises:
+ ValueError: If instance_id is not provided and the instance does not have an _id attribute.
+ """
+ logger.debug(f"'register' session={InstanceManager.get_session_id(session)}, {instance_id=}")
+
+ if instance_id is None:
+ if hasattr(instance, "_id"):
+ instance_id = getattr(instance, "_id")
+ else:
+ raise ValueError(f"`instance_id` is not provided and the instance '{instance}' has no `_id` attribute.")
+
+ key = (InstanceManager.get_session_id(session), instance_id)
+ InstanceManager._instances[key] = instance
+
+ @staticmethod
+ def register_many(*instances):
+ for instance in instances:
+ InstanceManager.register(None, instance)
+
+ @staticmethod
+ def remove(session: dict, instance_id: str):
+ """
+ Remove a specific instance by its key.
+ """
+ logger.debug(f"'remove' session={InstanceManager.get_session_id(session)}, {instance_id=}")
+ key = (InstanceManager.get_session_id(session), instance_id)
+ if key in InstanceManager._instances:
+ instance = InstanceManager._instances[key]
+ if hasattr(instance, "dispose"):
+ instance.dispose()
+ del InstanceManager._instances[key]
+
+ @staticmethod
+ def clear():
+ """
+ Clear all stored instances.
+ """
+ logger.debug(f"'clear'")
+ InstanceManager._instances.clear()
+
+ @staticmethod
+ def get_session_id(session: dict | None):
+ return session[SESSION_ID_KEY] if session else NOT_LOGGED
+
+
diff --git a/src/core/serializer.py b/src/core/serializer.py
new file mode 100644
index 0000000..99aaa4d
--- /dev/null
+++ b/src/core/serializer.py
@@ -0,0 +1,201 @@
+import copy
+
+from core.handlers import handlers
+from core.utils import has_tag, is_dictionary, is_list, is_object, is_set, is_tuple, is_primitive, importable_name, \
+ get_class, get_full_qualified_name, is_enum
+
+TAG_ID = "__id__"
+TAG_OBJECT = "__object__"
+TAG_TUPLE = "__tuple__"
+TAG_SET = "__set__"
+TAG_REF = "__ref__"
+TAG_ENUM = "__enum__"
+
+
+class Serializer:
+ def __init__(self, ref_helper=None):
+ self.ref_helper = ref_helper
+
+ self.ids = {}
+ self.objs = []
+ self.id_count = 0
+
+ def serialize(self, obj, use_refs=None):
+ """
+ From object to dictionary
+ :param obj:
+ :param use_refs: Sometimes it easier / quicker to use pickle !
+ :return:
+ """
+ if use_refs:
+ use_refs = set("root." + path for path in use_refs)
+
+ return self._serialize(obj, use_refs or set(), "root")
+
+ def deserialize(self, obj: dict):
+ """
+ From dictionary to object (or primitive)
+ :param obj:
+ :return:
+ """
+ if has_tag(obj, TAG_REF):
+ return self.ref_helper.load_ref(obj[TAG_REF])
+
+ if has_tag(obj, TAG_ID):
+ return self._restore_id(obj)
+
+ if has_tag(obj, TAG_TUPLE):
+ return tuple([self.deserialize(v) for v in obj[TAG_TUPLE]])
+
+ if has_tag(obj, TAG_SET):
+ return set([self.deserialize(v) for v in obj[TAG_SET]])
+
+ if has_tag(obj, TAG_ENUM):
+ return self._deserialize_enum(obj)
+
+ if has_tag(obj, TAG_OBJECT):
+ return self._deserialize_obj_instance(obj)
+
+ if (handler := handlers.get_handler(obj)) is not None:
+ return handler.deserialize(obj)
+
+ if is_list(obj):
+ return [self.deserialize(v) for v in obj]
+
+ if is_dictionary(obj):
+ return {k: self.deserialize(v) for k, v in obj.items()}
+
+ return obj
+
+ def _serialize(self, obj, use_refs: set | None, path):
+ if use_refs is not None and path in use_refs:
+ digest = self.ref_helper.save_ref(obj)
+ return {TAG_REF: digest}
+
+ if is_primitive(obj):
+ return obj
+
+ if is_tuple(obj):
+ return {TAG_TUPLE: [self._serialize(v, use_refs, path) for v in obj]}
+
+ if is_set(obj):
+ return {TAG_SET: [self._serialize(v, use_refs, path) for v in obj]}
+
+ if is_list(obj):
+ return [self._serialize(v, use_refs, path) for v in obj]
+
+ if is_dictionary(obj):
+ return {k: self._serialize(v, use_refs, path) for k, v in obj.items()}
+
+ if is_enum(obj):
+ return self._serialize_enum(obj, use_refs, path)
+
+ if is_object(obj):
+ return self._serialize_obj_instance(obj, use_refs, path)
+
+ raise Exception(f"Cannot serialize '{obj}'")
+
+ def _serialize_enum(self, obj, use_refs: set | None, path):
+ # check if the object was already seen
+ if (seen := self._check_already_seen(obj)) is not None:
+ return seen
+
+ data = {}
+ class_name = get_full_qualified_name(obj)
+ data[TAG_ENUM] = class_name + "." + obj.name
+ return data
+
+ def _serialize_obj_instance(self, obj, use_refs: set | None, path):
+ # check if the object was already seen
+ if (seen := self._check_already_seen(obj)) is not None:
+ return seen
+
+ # try to manage use_refs
+ current_obj_use_refs = getattr(obj, "use_refs")() if hasattr(obj, "use_refs") else None
+ if current_obj_use_refs:
+ use_refs.update(f"{path}.{sub_path}" for sub_path in current_obj_use_refs)
+
+ if (handler := handlers.get_handler(obj)) is not None:
+ return handler.serialize(obj)
+
+ # flatten
+ data = {}
+ cls = obj.__class__ if hasattr(obj, '__class__') else type(obj)
+ class_name = importable_name(cls)
+ data[TAG_OBJECT] = class_name
+
+ if hasattr(obj, "__dict__"):
+ for k, v in obj.__dict__.items():
+ data[k] = self._serialize(v, use_refs, f"{path}.{k}")
+
+ return data
+
+ def _check_already_seen(self, obj):
+ _id = self._exist(obj)
+ if _id is not None:
+ return {TAG_ID: _id}
+
+ # else:
+ self.ids[id(obj)] = self.id_count
+ self.objs.append(obj)
+ self.id_count = self.id_count + 1
+
+ return None
+
+ def _deserialize_enum(self, obj):
+ cls_name, enum_name = obj[TAG_ENUM].rsplit(".", 1)
+ cls = get_class(cls_name)
+ obj = getattr(cls, enum_name)
+ self.objs.append(obj)
+ return obj
+
+ def _deserialize_obj_instance(self, obj):
+
+ cls = get_class(obj[TAG_OBJECT])
+ instance = cls.__new__(cls)
+ self.objs.append(instance)
+
+ for k, v in obj.items():
+ value = self.deserialize(v)
+ setattr(instance, k, value)
+
+ return instance
+
+ def _restore_id(self, obj):
+ try:
+ return self.objs[obj[TAG_ID]]
+ except IndexError:
+ pass
+
+ def _exist(self, obj):
+ try:
+ v = self.ids[id(obj)]
+ return v
+ except KeyError:
+ return None
+
+
+class DebugSerializer(Serializer):
+ def __init__(self, ref_helper=None):
+ super().__init__(ref_helper)
+
+ def _deserialize_obj_instance(self, obj):
+ data = {TAG_OBJECT: obj[TAG_OBJECT]}
+ self.objs.append(data)
+
+ for k, v in obj.items():
+ value = self.deserialize(v)
+ data[k] = value
+
+ return data
+
+ def _deserialize_enum(self, obj):
+ cls_name, enum_name = obj[TAG_ENUM].rsplit(".", 1)
+ self.objs.append(enum_name)
+ return enum_name
+
+ def _restore_id(self, obj):
+ try:
+ return copy.deepcopy(self.objs[obj[TAG_ID]])
+ except IndexError:
+ pass
diff --git a/src/core/settings_management.py b/src/core/settings_management.py
new file mode 100644
index 0000000..7cdadbe
--- /dev/null
+++ b/src/core/settings_management.py
@@ -0,0 +1,210 @@
+import json
+import logging
+import os.path
+
+from core.dbengine import DbEngine, DbException
+from core.instance_manager import NO_SESSION, NOT_LOGGED
+from core.settings_objects import *
+
+load_settings_obj() # needed to make sure that the import of core is not removed
+
+FAKE_USER_ID = "FakeUserId"
+
+logger = logging.getLogger(__name__)
+
+
+class NoDefaultCls:
+ pass
+
+
+NoDefault = NoDefaultCls()
+
+
+class DummyDbEngine:
+ """
+ Dummy DB engine
+ Can only serialize object defined in settings_object module
+ Save everything in a single file
+ """
+
+ def __init__(self, setting_path="settings.json"):
+ self.db_path = setting_path
+
+ def save(self, user_id: str, entry: str, obj: object) -> bool:
+ if not hasattr(obj, "as_dict"):
+ raise Exception("'as_dict' not found. Not supported")
+
+ as_dict = getattr(obj, "as_dict")()
+ as_dict["__type__"] = type(obj).__name__
+
+ if os.path.exists(self.db_path):
+ with open(self.db_path, "r") as settings_file:
+ as_json = json.load(settings_file)
+ as_json[entry] = as_dict
+ with open(self.db_path, "w") as settings_file:
+ json.dump(as_json, settings_file)
+ else:
+ as_json = {entry: as_dict}
+ with open(self.db_path, "w") as settings_file:
+ json.dump(as_json, settings_file)
+
+ return True
+
+ def load(self, user_id: str, entry: str, digest: str = None):
+ try:
+ with open(self.db_path, "r") as settings_file:
+ as_json = json.load(settings_file)
+
+ as_dict = as_json[entry]
+ obj_type = as_dict.pop("__type__")
+ obj = globals()[obj_type]()
+ getattr(obj, "from_dict")(as_dict)
+ return obj
+ except Exception as ex:
+ raise DbException(f"Entry '{entry}' is not found.")
+
+ def is_initialized(self):
+ return os.path.exists(self.db_path)
+
+ def init(self):
+ pass
+
+
+class MemoryDbEngine:
+ """
+ Keeps everything in memory
+ """
+
+ def __init__(self):
+ self.db = {}
+
+ def init_db(self, entry, key, obj):
+ self.db[entry] = {key: obj}
+
+ def save(self, user_id: str, entry: str, obj: object) -> bool:
+ self.db[entry] = obj
+ return True
+
+ def load(self, user_id: str, entry: str, digest: str = None):
+ try:
+ return self.db[entry]
+ except KeyError:
+ return {}
+
+ def get(self, user_id: str, entry: str, key: str | None = None, digest=None):
+ return self.db[entry][key]
+
+ def put(self, user_id: str, entry, key: str, value: object):
+ if entry not in self.db:
+ self.db[entry] = {}
+ self.db[entry][key] = value
+
+ def is_initialized(self):
+ return True
+
+
+class SettingsManager:
+ def __init__(self, engine=None):
+ self._db_engine = engine or DbEngine()
+
+ def save(self, user_id: str, entry: str, obj: object):
+ return self._db_engine.save(user_id, entry, obj)
+
+ def load(self, user_id: str, entry: str):
+ return self._db_engine.load(user_id, entry)
+
+ def get_all(self, user_id: str, entry: str):
+ """"
+ Returns all the items of an entry
+ """
+ return self._db_engine.get(user_id, entry, None)
+
+ def put(self, session: dict, key: str, value: object):
+ """
+ Inserts or updates a key-value pair in the database for the current user session.
+ The method extracts the user ID and email from the session dictionary and
+ utilizes the database engine to perform the storage operation.
+
+ :param session: A dictionary containing session-specific details,
+ including 'user_id' and 'user_email'.
+ :type session: dict
+ :param key: The key under which the value should be stored in the database.
+ :type key: str
+ :param value: The value to be stored, associated with the specified key.
+ :type value: object
+ :return: The result of the database engine's put operation.
+ :rtype: object
+ """
+ user_id = session["user_id"] if session else NO_SESSION
+ user_email = session["user_email"] if session else NOT_LOGGED
+ return self._db_engine.put(user_email, str(user_id), key, value)
+
+ def get(self, session: dict, key: str | None = None, default=NoDefault):
+ """
+ Fetches a value associated with a specific key for a user session from the
+ database. If the key is not found in the database and a default value is
+ provided, returns the default value. If no default is provided and the key
+ is not found, raises a KeyError.
+
+ :param session: A dictionary containing session data. Must include "user_id"
+ and "user_email" keys.
+ :type session: dict
+ :param key: The key to fetch from the database for the given session user.
+ Defaults to None if not specified.
+ :type key: str | None
+ :param default: The default value to return if the key is not found in the
+ database. If not provided, raises KeyError when the key is missing.
+ :type default: Any
+ :return: The value associated with the key for the user session if found in
+ the database, or the provided default value if the key is not found.
+ """
+ try:
+ user_id = session["user_id"] if session else NO_SESSION
+ user_email = session["user_email"] if session else NOT_LOGGED
+
+ return self._db_engine.get(user_email, str(user_id), key)
+ except KeyError:
+ if default is NoDefault:
+ raise
+ else:
+ return default
+
+ def init_user(self, user_id: str, user_email: str):
+ """
+ Init the settings block space for a user
+ :param user_id:
+ :param user_email:
+ :return:
+ """
+ if not self._db_engine.exists(user_id):
+ self._db_engine.save(user_email, user_id, {})
+
+ def get_db_engine_root(self):
+ return os.path.abspath(self._db_engine.root)
+
+ def get_db_engine(self):
+ return self._db_engine
+
+
+class SettingsTransaction:
+ def __init__(self, session, settings_manager: SettingsManager):
+ self._settings_manager = settings_manager
+ self._session = session
+ self._user_id = session["user_id"] if session else NO_SESSION
+ self._user_email = session["user_email"] if session else NOT_LOGGED
+ self._entries = None
+
+ def __enter__(self):
+ self._entries = self._settings_manager.load(self._user_email, self._user_id)
+ return self
+
+ def put(self, key: str, value: object):
+ self._entries[key] = value
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ if exc_type is None:
+ self._settings_manager.save(self._user_email, self._user_id, self._entries)
+
+#
+# settings_manager = SettingsManager()
+# settings_manager.init()
diff --git a/src/core/settings_objects.py b/src/core/settings_objects.py
new file mode 100644
index 0000000..df7ac09
--- /dev/null
+++ b/src/core/settings_objects.py
@@ -0,0 +1,303 @@
+import dataclasses
+
+from pandas import DataFrame
+
+
+def load_settings_obj():
+ """
+ Do not remove. Used to dynamically load objects
+ :return:
+ """
+ pass
+
+
+BUDGET_TRACKER_SETTINGS_ENTRY = "BudgetTrackerSettings"
+BUDGET_TRACKER_MAPPINGS_ENTRY = "BudgetTrackerMappings"
+
+PROJECTS_CODES_SETTINGS_ENTRY = "ProjectsCodesSettings"
+PROJECTS_CODES_ENTRY = "ProjectsCodes"
+
+BUDGET_TRACKER_FILES_ENTRY = "BudgetTrackerFiles"
+BUDGETS_FILES_ENTRY = "BudgetFiles"
+VIEWS_DEFINITIONS_ENTRY = "ViewsDefinitions"
+MAPPINGS_DEFINITIONS_ENTRY = "MappingsDefinitions"
+IMPORT_SETTINGS_ENTRY = "ImportSettingsDefinitions"
+VIEWS_DEFINITIONS_NEW_ENTRY = "ViewsDefinitionsNew"
+
+# Get the columns names from BudgetTrackerSettings
+COL_ROW_NUM = "col_row_num"
+COL_PROJECT = "col_project"
+COL_OWNER = "col_owner"
+COL_CAPEX = "col_capex"
+COL_DETAILS = "col_details"
+COL_SUPPLIER = "col_supplier"
+COL_BUDGET = "col_budget"
+COL_ACTUAL = "col_actual"
+COL_FORCAST5_7 = "col_forecast5_7"
+# other columns
+COL_INDEX = "col_index"
+COL_LEVEL1 = "col_level1"
+COL_LEVEL2 = "col_level2"
+COL_LEVEL3 = "col_level3"
+COL_PERCENTAGE = "col_percentage"
+
+
+@dataclasses.dataclass
+class BaseSettingObj:
+ def as_dict(self):
+ return dataclasses.asdict(self)
+
+ def from_dict(self, as_dict):
+ for k, v in as_dict.items():
+ setattr(self, k, v)
+
+ return self
+
+ def format_props(self):
+ return {}
+
+ def get_display_name(self, prop):
+ return self.format_props().get(prop, prop)
+
+ def use_refs(self) -> set:
+ """
+ List of attributes to store as reference, rather than as dictionary
+ :return:
+ """
+ pass
+
+
+@dataclasses.dataclass
+class BudgetTrackerSettings(BaseSettingObj):
+ """
+ Class that holds the settings to read the 'Suivi de budget' xls file
+ """
+ spread_sheet: str = "full charges"
+ col_row_num: str = "A"
+ col_project: str = "D"
+ col_owner: str = "E"
+ col_capex: str = "G"
+ col_details: str = "H"
+ col_supplier: str = "I"
+ col_budget_amt: str = "BQ"
+ col_actual_amt: str = "AO"
+ col_forecast5_7_amt: str = "BC"
+
+ def format_props(self):
+ return {
+ "spread_sheet": "Spread Sheet",
+ "col_row_num": "Row Number",
+ "col_project": "Project",
+ "col_owner": "Owner",
+ "col_capex": "Capex",
+ "col_details": "Details",
+ "col_supplier": "Supplier",
+ "col_budget_amt": "Budget",
+ "col_actual_amt": "Actual",
+ "col_forecast5_7_amt": "Forecast 5+7"
+ }
+
+ def get_key(self):
+ return self.spread_sheet
+
+
+@dataclasses.dataclass
+class BudgetTrackerMappings(BaseSettingObj):
+ """
+ This class holds the link between the old nomenclature
+ as it's used in xsl 'budget tracking' file
+ and the new one we want to use from the MDM (Master Data Management)
+ """
+
+ @dataclasses.dataclass
+ class Mapping:
+ # Keys to use for the matching
+ col_index: int = 0
+ col_project: str = None
+ col_owner: str = None
+ col_details: str = None
+ col_supplier: str = None
+
+ # Values to add
+ col_level1: str = None
+ col_level2: str = None
+ col_level3: str = None
+ col_percentage: int = None
+
+ mappings: list[Mapping] = None
+
+ def from_dict(self, as_dict):
+ self.mappings = [BudgetTrackerMappings.Mapping(**item) for item in as_dict["mappings"]]
+ return self
+
+
+@dataclasses.dataclass
+class ProjectsCodesSettings(BaseSettingObj):
+ """
+ Class that holds the settings to read the project code xls file
+ """
+ spread_sheet: str = "Proposition"
+ col_level1: str = "E"
+ col_level2: str = "F"
+ col_level3: str = "G"
+ min_row: int = 150
+
+ def format_props(self):
+ return {
+ "col_level1": "Niveau 1",
+ "col_level2": "Niveau 2",
+ "col_level3": "Niveau 3",
+ }
+
+
+@dataclasses.dataclass
+class ProjectsCodes(BaseSettingObj):
+ """
+ Class that lists the projects id
+ It only stores the aggregated value of the 3 levels, concatenated with a hyphen '-'
+ """
+ level1: list = None
+ level2: dict = None
+ level3: dict = None
+
+
+@dataclasses.dataclass
+class BudgetTrackerFile(BaseSettingObj):
+ year: int = None
+ month: int = None
+ file_name: str = None # original file name
+ sheet_name: str = None # when uploaded from a file, sheet to use
+ grid_settings: dict = None
+ data: DataFrame = dataclasses.field(default=None, compare=False)
+
+ @staticmethod
+ def use_refs() -> set:
+ return {"data"}
+
+ def __str__(self):
+ return f"{self.year}-{self.month}"
+
+
+@dataclasses.dataclass
+class BudgetTrackerFiles(BaseSettingObj):
+ """
+ Stores all the budget tracker files
+ """
+ files: list[BudgetTrackerFile] = dataclasses.field(default_factory=list)
+
+
+@dataclasses.dataclass
+class ViewDefinition(BaseSettingObj):
+ """
+ This class is used to define a new set of codification
+ """
+ name: str = None
+ sheet_name: str = None # when uploaded from a file, sheet to use
+ grid_settings: dict = None
+ data: DataFrame = dataclasses.field(default=None, compare=False)
+
+ @staticmethod
+ def use_refs() -> set:
+ return {"data"}
+
+ def __str__(self):
+ return self.name
+
+
+@dataclasses.dataclass
+class ViewsDefinitions(BaseSettingObj):
+ """
+ This class is used to define a new set of codification
+ """
+ views: list[ViewDefinition] = dataclasses.field(default_factory=list)
+
+
+@dataclasses.dataclass
+class MappingDefinition(BaseSettingObj):
+ """
+ Define the items from the left will translate into items in the right
+ We only need to store the names of the columns of the two sides
+ """
+ name: str = None # name given to this mapping
+ file: str = None # budget tracking file from with the mapping is constructed
+ view: str = None # view from with the mapping is constructed
+ keys: list[str] = None # columns from the tracking file
+ values: list[str] = None # columns from the view
+ grid_settings: dict = None # keys + values + percent display names
+ data: DataFrame = dataclasses.field(default=None, compare=False)
+
+ @staticmethod
+ def use_refs() -> set:
+ return {"data"}
+
+ def __str__(self):
+ return self.name
+
+
+@dataclasses.dataclass
+class MappingsDefinitions(BaseSettingObj):
+ mappings: list[MappingDefinition] = dataclasses.field(default_factory=list)
+
+
+@dataclasses.dataclass
+class ImportSettingsDefinition(BaseSettingObj):
+ @dataclasses.dataclass
+ class ColumnDef:
+ col_name: str = None
+ col_location: str = None
+ col_display_name: str = None
+
+ """
+ Defines how to import data from an excel file
+ """
+ name: str = None # name of the setting
+ setting_type: str = None
+ spread_sheet: str = None # spreadsheet where the info is located
+ columns_definitions: list[ColumnDef] = None
+
+ def get_key(self):
+ return f"{self.name}|{self.setting_type}"
+
+ def __str__(self):
+ return self.name
+
+
+@dataclasses.dataclass
+class BudgetFile(BaseSettingObj):
+ name: str = None
+ year: int = None
+ month: int = None
+ file_name: str = None # original file name
+ original_import_settings: str = None # import setting used to import
+ sheet_name: str = None # when uploaded from a file, sheet to use
+ grid_settings: dict = None
+ data: DataFrame = dataclasses.field(default=None, compare=False) # content of the file
+
+ @staticmethod
+ def use_refs() -> set:
+ return {"data"}
+
+ def get_key(self):
+ return f"{self.name}-{self.year}-{self.month}"
+
+ def __str__(self):
+ return f"{self.name}-{self.year}-{self.month}"
+
+
+@dataclasses.dataclass
+class ViewDefinition2(BaseSettingObj):
+ """
+ This class is used to define a new set of codification
+ """
+ name: str = None
+ import_settings_name: str = None # when uploaded from a file, sheet to use
+ file_name: str = None
+ grid_settings: dict = None
+ data: DataFrame = dataclasses.field(default=None, compare=False)
+
+ @staticmethod
+ def use_refs() -> set:
+ return {"data"}
+
+ def __str__(self):
+ return self.name
diff --git a/src/core/user_dao.py b/src/core/user_dao.py
new file mode 100644
index 0000000..6bf50f5
--- /dev/null
+++ b/src/core/user_dao.py
@@ -0,0 +1,258 @@
+import logging
+import secrets
+from datetime import datetime
+from typing import Any
+
+from .user_database import user_db
+
+logger = logging.getLogger(__name__)
+
+class UserDAO:
+ """Data Access Object for user management."""
+
+ @staticmethod
+ def create_user(username: str, email: str, password: str | None = None, github_id: str | None = None) -> int:
+ """
+ Create a new user with email/password or GitHub authentication.
+
+ Args:
+ username: The username
+ email: The user's email
+ password: The user's password (optional)
+ github_id: GitHub user ID (optional)
+
+ Returns:
+ int: ID of the new user or 0 if creation failed
+ """
+ try:
+ with user_db.get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Check if user already exists
+ cursor.execute(
+ "SELECT id FROM users WHERE email = ? OR username = ? OR (github_id = ? AND github_id IS NOT NULL)",
+ (email, username, github_id)
+ )
+
+ if cursor.fetchone():
+ # User already exists
+ return 0
+
+ # Prepare values for insertion
+ password_hash = None
+ salt = None
+
+ if password:
+ # Generate salt and hash password for email auth
+ salt = secrets.token_hex(16)
+ password_hash = user_db._hash_password(password, salt)
+
+ cursor.execute('''
+ INSERT INTO users (username, email, password_hash, salt, github_id, is_admin)
+ VALUES (?, ?, ?, ?, ?, 0)
+ ''', (username, email, password_hash, salt, github_id))
+
+ conn.commit()
+ return cursor.lastrowid
+ except Exception as e:
+ logger.error(f"Error creating user: {e}")
+ return 0
+
+ @staticmethod
+ def authenticate_email(email: str, password: str) -> dict[str, Any] | None:
+ """
+ Authenticate a user with email and password.
+
+ Args:
+ email: The user's email
+ password: The user's password
+
+ Returns:
+ Dict or None: User record if authentication succeeds, None otherwise
+ """
+ with user_db.get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Get user record by email
+ cursor.execute(
+ "SELECT id, username, email, password_hash, salt, is_admin FROM users WHERE email = ?",
+ (email,)
+ )
+
+ user = cursor.fetchone()
+ if not user or not user['password_hash'] or not user['salt']:
+ return None
+
+ # Hash the provided password with the stored salt
+ password_hash = user_db._hash_password(password, user['salt'])
+
+ # Check if password matches
+ if password_hash != user['password_hash']:
+ return None
+
+ # Update last login time
+ cursor.execute(
+ "UPDATE users SET last_login = ? WHERE id = ?",
+ (datetime.now().isoformat(), user['id'])
+ )
+ conn.commit()
+
+ # Return user info
+ return dict(user)
+
+ @staticmethod
+ def find_or_create_github_user(github_id: str, username: str, email: str | None) -> dict[str, Any] | None:
+ """
+ Find existing GitHub user or create a new one.
+
+ Args:
+ github_id: GitHub user ID
+ username: The username from GitHub
+ email: The email from GitHub (may be None)
+
+ Returns:
+ Dict or None: User record if found or created, None on error
+ """
+ with user_db.get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Try to find user by GitHub ID
+ cursor.execute(
+ "SELECT id, username, email, is_admin FROM users WHERE github_id = ?",
+ (github_id,)
+ )
+
+ user = cursor.fetchone()
+ if user:
+ # Update last login time
+ cursor.execute(
+ "UPDATE users SET last_login = ? WHERE id = ?",
+ (datetime.now().isoformat(), user['id'])
+ )
+ conn.commit()
+ return dict(user)
+
+ # Create new user
+ # Use GitHub username with random suffix if email not provided
+ user_email = email or f"{username}-{secrets.token_hex(4)}@github.user"
+
+ try:
+ cursor.execute('''
+ INSERT INTO users (username, email, github_id, is_admin)
+ VALUES (?, ?, ?, 0)
+ ''', (username, user_email, github_id))
+
+ user_id = cursor.lastrowid
+ conn.commit()
+
+ # Return the new user info
+ return {
+ 'id': user_id,
+ 'username': username,
+ 'email': user_email,
+ 'is_admin': 0
+ }
+ except Exception as e:
+ logger.error(f"Error creating GitHub user: {e}")
+ return None
+
+ @staticmethod
+ def get_user_by_id(user_id: int) -> dict[str, Any] | None:
+ """
+ Get a user by ID.
+
+ Args:
+ user_id: The user ID
+
+ Returns:
+ Dict or None: User record if found, None otherwise
+ """
+ with user_db.get_connection() as conn:
+ cursor = conn.cursor()
+
+ cursor.execute(
+ "SELECT id, username, email, is_admin, created_at, last_login FROM users WHERE id = ?",
+ (user_id,)
+ )
+
+ user = cursor.fetchone()
+ return dict(user) if user else None
+
+ @staticmethod
+ def get_all_users(limit: int = 100, offset: int = 0) -> list[dict[str, Any]]:
+ """
+ Get all users with pagination.
+
+ Args:
+ limit: Maximum number of users to return
+ offset: Number of users to skip
+
+ Returns:
+ List of user records
+ """
+ with user_db.get_connection() as conn:
+ cursor = conn.cursor()
+
+ cursor.execute('''
+ SELECT id, username, email, is_admin, created_at, last_login,
+ (github_id IS NOT NULL) as is_github_user
+ FROM users
+ ORDER BY created_at DESC
+ LIMIT ? OFFSET ?
+ ''', (limit, offset))
+
+ return [dict(user) for user in cursor.fetchall()]
+
+ @staticmethod
+ def set_admin_status(user_id: int, is_admin: bool) -> bool:
+ """
+ Change a user's admin status.
+
+ Args:
+ user_id: The user ID
+ is_admin: True to make admin, False to remove admin status
+
+ Returns:
+ bool: True if successful, False otherwise
+ """
+ try:
+ with user_db.get_connection() as conn:
+ cursor = conn.cursor()
+
+ cursor.execute(
+ "UPDATE users SET is_admin = ? WHERE id = ?",
+ (1 if is_admin else 0, user_id)
+ )
+
+ conn.commit()
+ return cursor.rowcount > 0
+ except Exception as e:
+ logger.error(f"Error setting admin status: {e}")
+ return False
+
+ @staticmethod
+ def delete_user(user_id: int) -> bool:
+ """
+ Delete a user and all their data.
+
+ Args:
+ user_id: The user ID
+
+ Returns:
+ bool: True if successful, False otherwise
+ """
+ try:
+ with user_db.get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Delete user's history records
+ cursor.execute("DELETE FROM title_history WHERE user_id = ?", (user_id,))
+
+ # Delete user
+ cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
+
+ conn.commit()
+ return cursor.rowcount > 0
+ except Exception as e:
+ logger.error(f"Error deleting user: {e}")
+ return False
diff --git a/src/core/user_database.py b/src/core/user_database.py
new file mode 100644
index 0000000..28a2494
--- /dev/null
+++ b/src/core/user_database.py
@@ -0,0 +1,115 @@
+import logging
+import sqlite3
+import os
+from contextlib import contextmanager
+import config
+import hashlib
+import secrets
+
+logger = logging.getLogger("UserDatabase")
+
+class Database:
+ """Handles database connections and initialization."""
+
+ def __init__(self, db_path=None):
+ """
+ Initialize the database connection.
+
+ Args:
+ db_path: Path to the SQLite database file (defaults to config setting)
+ """
+ # If db_path is None or empty, use a default path
+ self.db_path = db_path or config.DB_PATH
+ if not self.db_path:
+ # Set default path if DB_PATH is empty
+ self.db_path = "tools.db"
+ self._initialize_db()
+
+ def _initialize_db(self):
+ """Create database tables if they don't exist."""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Create the users table
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE,
+ email TEXT UNIQUE,
+ password_hash TEXT,
+ salt TEXT,
+ github_id TEXT UNIQUE,
+ is_admin BOOLEAN DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ last_login TIMESTAMP
+ )
+ ''')
+ logger.info("Created users table")
+
+
+ # Check if we need to create an admin user
+ cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = 1")
+ if cursor.fetchone()[0] == 0:
+ if config.ADMIN_EMAIL and config.ADMIN_PASSWORD:
+ # Create admin user if credentials are provided in config
+ salt = secrets.token_hex(16)
+ password_hash = self._hash_password(config.ADMIN_PASSWORD, salt)
+
+ cursor.execute('''
+ INSERT INTO users (username, email, password_hash, salt, is_admin)
+ VALUES (?, ?, ?, ?, 1)
+ ''', ('admin', config.ADMIN_EMAIL, password_hash, salt))
+ logger.info("Created admin user")
+ else:
+ logger.error(f"Failed to create admin user. Admin user is '{config.ADMIN_EMAIL}'.")
+
+ conn.commit()
+
+ @staticmethod
+ def _hash_password(password, salt):
+ """
+ Hash a password with the given salt using PBKDF2.
+
+ Args:
+ password: The plain text password
+ salt: The salt to use
+
+ Returns:
+ str: The hashed password
+ """
+ # Use PBKDF2 with SHA-256, 100,000 iterations
+ return hashlib.pbkdf2_hmac(
+ 'sha256',
+ password.encode('utf-8'),
+ salt.encode('utf-8'),
+ 100000
+ ).hex()
+
+ @contextmanager
+ def get_connection(self):
+ """
+ Context manager for database connections.
+
+ Yields:
+ sqlite3.Connection: Active database connection
+ """
+ # Check if db_path has a directory component
+ db_dir = os.path.dirname(self.db_path)
+
+ # Only try to create directories if there's a directory path
+ if db_dir:
+ os.makedirs(db_dir, exist_ok=True)
+
+ # Connect to the database
+ conn = sqlite3.connect(self.db_path)
+
+ # Configure connection
+ conn.row_factory = sqlite3.Row # Use dictionary-like rows
+
+ try:
+ yield conn
+ finally:
+ conn.close()
+
+# Create a singleton instance
+user_db = Database()
\ No newline at end of file
diff --git a/src/core/utils.py b/src/core/utils.py
new file mode 100644
index 0000000..a802ccf
--- /dev/null
+++ b/src/core/utils.py
@@ -0,0 +1,380 @@
+import base64
+import hashlib
+import importlib
+import inspect
+import pkgutil
+import re
+import types
+import uuid
+from enum import Enum
+from io import BytesIO
+
+import pandas as pd
+
+PRIMITIVES = (str, bool, type(None), int, float)
+
+
+def get_stream_digest(stream):
+ """
+ Compute a SHA256 from a stream
+ :param stream:
+ :type stream:
+ :return:
+ :rtype:
+ """
+ sha256_hash = hashlib.sha256()
+ stream.seek(0)
+ for byte_block in iter(lambda: stream.read(4096), b""):
+ sha256_hash.update(byte_block)
+
+ return sha256_hash.hexdigest()
+
+
+def has_tag(obj, tag):
+ """
+
+ :param obj:
+ :param tag:
+ :return:
+ """
+ return type(obj) is dict and tag in obj
+
+
+def is_primitive(obj):
+ """
+
+ :param obj:
+ :return:
+ """
+ return isinstance(obj, PRIMITIVES)
+
+
+def is_dictionary(obj):
+ """
+
+ :param obj:
+ :return:
+ """
+ return isinstance(obj, dict)
+
+
+def is_list(obj):
+ """
+
+ :param obj:
+ :return:
+ """
+ return isinstance(obj, list)
+
+
+def is_set(obj):
+ """
+
+ :param obj:
+ :return:
+ """
+ return isinstance(obj, set)
+
+
+def is_tuple(obj):
+ """
+
+ :param obj:
+ :return:
+ """
+ return isinstance(obj, tuple)
+
+
+def is_enum(obj):
+ return isinstance(obj, Enum)
+
+
+def is_object(obj):
+ """Returns True is obj is a reference to an object instance."""
+
+ return (isinstance(obj, object) and
+ not isinstance(obj, (type,
+ types.FunctionType,
+ types.BuiltinFunctionType,
+ types.GeneratorType)))
+
+
+def get_full_qualified_name(obj):
+ """
+ Returns the full qualified name of a class (including its module name )
+ :param obj:
+ :return:
+ """
+ if obj.__class__ == type:
+ module = obj.__module__
+ if module is None or module == str.__class__.__module__:
+ return obj.__name__ # Avoid reporting __builtin__
+ else:
+ return module + '.' + obj.__name__
+ else:
+ module = obj.__class__.__module__
+ if module is None or module == str.__class__.__module__:
+ return obj.__class__.__name__ # Avoid reporting __builtin__
+ else:
+ return module + '.' + obj.__class__.__name__
+
+
+def importable_name(cls):
+ """
+ Fully qualified name (prefixed by builtin when needed)
+ """
+ # Use the fully-qualified name if available (Python >= 3.3)
+ name = getattr(cls, '__qualname__', cls.__name__)
+
+ # manage python 2
+ lookup = dict(__builtin__='builtins', exceptions='builtins')
+ module = lookup.get(cls.__module__, cls.__module__)
+
+ return f"{module}.{name}"
+
+
+def get_class(qualified_class_name: str):
+ """
+ Dynamically loads and returns a class type from its fully qualified name.
+ Note that the class is not instantiated.
+
+ :param qualified_class_name: Fully qualified name of the class (e.g., 'some.module.ClassName').
+ :return: The class object.
+ :raises ImportError: If the module cannot be imported.
+ :raises AttributeError: If the class cannot be resolved in the module.
+ """
+ module_name, class_name = qualified_class_name.rsplit(".", 1)
+
+ try:
+ module = importlib.import_module(module_name)
+ except ModuleNotFoundError as e:
+ raise ImportError(f"Could not import module '{module_name}' for '{qualified_class_name}': {e}")
+
+ if not hasattr(module, class_name):
+ raise AttributeError(f"Component '{class_name}' not found in '{module.__name__}'.")
+
+ return getattr(module, class_name)
+
+
+def make_html_id(s: str | None) -> str | None:
+ """
+ Creates a valid html id
+ :param s:
+ :return:
+ """
+ if s is None:
+ return None
+
+ s = str(s).strip()
+ # Replace spaces and special characters with hyphens or remove them
+ s = re.sub(r'[^a-zA-Z0-9_-]', '-', s)
+
+ # Ensure the ID starts with a letter or underscore
+ if not re.match(r'^[a-zA-Z_]', s):
+ s = 'id_' + s # Add a prefix if it doesn't
+
+ # Collapse multiple consecutive hyphens into one
+ s = re.sub(r'-+', '-', s)
+
+ # Replace trailing hyphens with underscores
+ s = re.sub(r'-+$', '_', s)
+
+ return s
+
+
+def snake_case_to_capitalized_words(s: str) -> str:
+ """
+ Try to (re)create the column title from the column id
+ >>> assert snake_case_to_capitalized_words("column_id") == "Column Id"
+ >>> assert snake_case_to_capitalized_words("this_is_a_column_name") == "This Is A Column Name"
+ :param s:
+ :return:
+ """
+ parts = s.split('_')
+ capitalized_parts = [part.capitalize() for part in parts]
+
+ # Join the capitalized parts with spaces
+ transformed_name = ' '.join(capitalized_parts)
+
+ return transformed_name
+
+
+def make_column_id(s: str | None):
+ if s is None:
+ return None
+
+ res = re.sub('-', '_', make_html_id(s)) # replace '-' by '_'
+ return res.lower() # no uppercase
+
+
+def update_elements(elts, updates: list[dict]):
+ """
+ walk through elements and update them if needed
+ :param elts:
+ :param updates:
+ :return:
+ """
+
+ def _update_elt(_elt):
+ if hasattr(_elt, 'attrs'):
+ for blue_print in updates:
+ if "id" in _elt.attrs and _elt.attrs["id"] == blue_print["id"]:
+ method = blue_print["method"]
+ _elt.attrs[method] = blue_print["value"]
+
+ if hasattr(_elt, "children"):
+ for child in _elt.children:
+ _update_elt(child)
+
+ if elts is None:
+ return None
+
+ to_use = elts if isinstance(elts, (list, tuple, set)) else [elts]
+ for elt in to_use:
+ _update_elt(elt)
+
+ return elts
+
+
+def get_sheets_names(file_content):
+ try:
+ excel_file = pd.ExcelFile(BytesIO(file_content))
+ sheet_names = excel_file.sheet_names
+ except Exception:
+ sheet_names = []
+
+ return sheet_names
+
+
+def to_bool(value: str):
+ if isinstance(value, bool):
+ return value
+
+ if value is None:
+ return False
+
+ if not isinstance(value, str):
+ raise NotImplemented("Cannot convert to bool")
+
+ return value.lower() in ("yes", "true", "t", "1")
+
+
+def from_bool(value: bool):
+ return "true" if value else "false"
+
+
+def append_once(lst: list, elt):
+ if elt in lst:
+ return
+
+ lst.append(elt)
+
+
+def find_classes_in_modules(modules, base_class_name):
+ """
+ Recursively search for all classes in the given list of modules (and their submodules)
+ that inherit from a specified base class.
+
+ :param modules: List of top-level module names (e.g., ["core.settings_objects", "another.module"])
+ :param base_class_name: Name of the base class to search for (e.g., "BaseSettingObj")
+ """
+ # List to store matching classes
+ derived_classes = []
+
+ def inspect_module(_module_name):
+ """Recursively inspect a module and its submodules for matching classes."""
+ try:
+ # Import the module dynamically
+ module = importlib.import_module(_module_name)
+
+ # Iterate over all objects in the module
+ for name, obj in inspect.getmembers(module, inspect.isclass):
+ # Check if the class inherits from the specified base class
+ for base in obj.__bases__:
+ if base.__name__ == base_class_name:
+ derived_classes.append(f"{_module_name}.{name}")
+
+ # Recursively inspect submodules
+ if hasattr(module, "__path__"): # Check if the module has submodules
+ for submodule_info in pkgutil.iter_modules(module.__path__):
+ inspect_module(f"{_module_name}.{submodule_info.name}")
+
+ except Exception:
+ pass
+
+ # Start inspecting from the top-level modules
+ for module_name in modules:
+ inspect_module(module_name)
+
+ return derived_classes
+
+
+def instantiate_class(qualified_class_name):
+ """
+ Dynamically instantiates a class provided its full module path. The function takes
+ the fully-qualified class path, imports the corresponding module at runtime,
+ retrieves the class from the module, and instantiates it. Any exceptions during
+ this process are caught and logged.
+
+ :param qualified_class_name: Full dot-separated path to the class to be instantiated.
+ Example: 'module.submodule.ClassName'
+ :type qualified_class_name: str
+ :return: An instance of the dynamically instantiated class.
+ :rtype: object
+ :raises ValueError: If the class path fails to split correctly into module and
+ class parts.
+ :raises ModuleNotFoundError: If the specified module cannot be imported.
+ :raises AttributeError: If the specified class does not exist in the module.
+ :raises TypeError: For errors in class instantiation process.
+ """
+ try:
+ # Split module and class name
+ module_name, class_name = qualified_class_name.rsplit(".", 1)
+
+ # Dynamically import the module
+ module = importlib.import_module(module_name)
+
+ # Get the class from the module
+ cls = getattr(module, class_name)
+
+ # Instantiate the class (pass arguments here if required)
+ return cls()
+ except Exception as e:
+ print(f"Failed to instantiate {qualified_class_name}: {e}")
+
+
+def get_unique_id(prefix: str = None):
+ suffix = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip(b'=').decode('ascii')
+ if prefix is None:
+ return suffix
+ else:
+ return f"{prefix}_{suffix}"
+
+
+def merge_classes(*args):
+ all_elements = []
+ for element in args:
+ if element is None or element == '':
+ continue
+
+ if isinstance(element, (tuple, list, set)):
+ all_elements.extend(element)
+
+ elif isinstance(element, dict):
+ if "cls" in element:
+ all_elements.append(element.pop("cls"))
+ elif "class" in element:
+ all_elements.append(element.pop("class"))
+
+ elif isinstance(element, str):
+ all_elements.append(element)
+
+ else:
+ raise ValueError(f"Cannot merge {element} of type {type(element)}")
+
+ if all_elements:
+ # Remove duplicates while preserving order
+ unique_elements = list(dict.fromkeys(all_elements))
+ return " ".join(unique_elements)
+ else:
+ return None
diff --git a/src/logging.yaml b/src/logging.yaml
new file mode 100644
index 0000000..00002f8
--- /dev/null
+++ b/src/logging.yaml
@@ -0,0 +1,43 @@
+version: 1
+disable_existing_loggers: False
+
+formatters:
+ default:
+ format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+
+handlers:
+ console:
+ class: logging.StreamHandler
+ formatter: default
+
+root:
+ level: DEBUG
+ handlers: [console]
+
+
+loggers:
+ # Explicit logger configuration (example)
+ multipart.multipart:
+ level: INFO
+ handlers: [console]
+ propagate: False
+
+ watchfiles.main:
+ level: ERROR
+ handlers: [console]
+ propagate: False
+
+ core.dbengine:
+ level: ERROR
+ handlers: [console]
+ propagate: False
+
+ InstanceManager:
+ level: INFO
+ handlers: [console]
+ propagate: False
+
+ AddStuffApp:
+ level: INFO
+ handlers: [ console ]
+ propagate: False
\ No newline at end of file
diff --git a/src/main.py b/src/main.py
index 5596b44..318802c 100644
--- a/src/main.py
+++ b/src/main.py
@@ -1,16 +1,222 @@
-# This is a sample Python script.
+# global layout
+import logging.config
-# Press Shift+F10 to execute it or replace it with your code.
-# Press Double Shift to search everywhere for classes, files, tool windows, actions, and settings.
+import yaml
+from fasthtml.common import *
+
+from assets.css import my_managing_tools_style
+from auth.auth_manager import AuthManager
+from components.DrawerLayoutOld import DrawerLayout as DrawerLayoutOld
+from components.DrawerLayoutOld import Page
+from components.addstuff.AddStuffApp import add_stuff_app
+from components.addstuff.constants import ROUTE_ROOT as ADD_STUFF_ROUTE_ROOT
+from components.datagrid.DataGrid import DATAGRID_PATH, datagrid_app
+from components.datagrid_new.DataGridApp import datagrid_new_app
+from components.datagrid_new.constants import ROUTE_ROOT as DATAGRID_NEW_ROUTE_ROOT
+from components.debugger.DebuggerApp import debugger_app
+from components.debugger.constants import ROUTE_ROOT as DEBUGGER_ROUTE_ROOT
+from components.drawerlayout.DrawerLayoutApp import drawer_layout_app
+from components.drawerlayout.components import DrawerLayout
+from components.drawerlayout.components.DrawerLayout import DrawerLayout
+from components.drawerlayout.constants import ROUTE_ROOT as DRAWER_LAYOUT_ROUTE_ROOT
+from components.form.FormApp import form_app
+from components.form.constants import ROUTE_ROOT as FORM_ROUTE_ROOT
+from components.login.LoginApp import login_app
+from components.login.components.Login import Login
+from components.login.constants import ROUTE_ROOT as LOGIN_ROUTE_ROOT
+from components.login.constants import Routes as LoginRoutes
+from components.page_layout_new import page_layout_new, page_layout_lite
+from components.register.RegisterApp import register_app
+from components.register.components.Register import Register
+from components.register.constants import ROUTE_ROOT as REGISTER_ROUTE_ROOT
+from components.register.constants import Routes as RegisterRoutes
+from components.tabs.TabsApp import tabs_app
+from components.tabs.constants import ROUTE_ROOT as TABS_ROUTE_ROOT
+from components.themecontroller.ThemeControllerApp import theme_controller_app
+from components.themecontroller.constants import ROUTE_ROOT as THEME_CONTROLLER_ROUTE_ROOT
+from constants import Routes
+from core.dbengine import DbException
+from core.instance_manager import NO_SESSION, NOT_LOGGED, InstanceManager
+from core.settings_management import SettingsManager
+from pages.admin_import_settings import AdminImportSettings, IMPORT_SETTINGS_PATH, import_settings_app
+from pages.another_grid import get_datagrid2
+from pages.basic_test import BASIC_TEST_PATH, basic_test_app, get_basic_test
+from pages.testing_datagrid import get_datagrid
+from pages.testing_restore_state import testing_restore_state
+
+# logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
+# Load the YAML logging configuration
+with open('logging.yaml', 'r') as f:
+ config = yaml.safe_load(f)
+
+# At the top of your script or module
+logging.config.dictConfig(config)
+
+# daisy_ui_links_v4 = (
+# Link(href="https://cdn.jsdelivr.net/npm/daisyui@latest/dist/full.min.css", rel="stylesheet", type="text/css"),
+# Script(src="https://cdn.tailwindcss.com"),
+# )
+
+daisy_ui_links_v4 = (
+ Link(href="./assets/daisyui-4.12.10-full-min.css", rel="stylesheet", type="text/css"),
+ Script(src="./assets/tailwindcss.js"),
+)
+
+daisy_ui_links = (
+ Link(href="https://cdn.jsdelivr.net/npm/daisyui@5", rel="stylesheet", type="text/css"),
+ Link(href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css", rel="stylesheet", type="text/css"),
+ Script(src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"),
+)
+
+main_links = (Script(src="./assets/main.js"),
+ Link(rel="stylesheet", href="./assets/main.css", type="text/css"),)
+
+drawer_layout = (Script(src="./components/drawerlayout/assets/DrawerLayout.js"),
+ Link(rel="stylesheet", href="./components/drawerlayout/assets/DrawerLayout.css"),)
+
+datagridOld = (Script(src="./components/datagrid/DataGrid.js"),
+ Link(rel="stylesheet", href="./components/datagrid/DataGrid.css"))
+
+drw_layout_old = (Script(src="./assets/DrawerLayout.js", defer=True),
+ Link(rel="stylesheet", href="./assets/DrawerLayout.css"))
+
+datagrid = (Script(src="./components/datagrid_new/assets/Datagrid.js"),
+ Link(rel="stylesheet", href="./components/datagrid_new/assets/Datagrid.css"))
+
+addstuff = (Script(src="./components/addstuff/assets/addstuff.js"),)
+
+tabs = (Script(src="./components/tabs/assets/tabs.js"),
+ Link(rel="stylesheet", href="./components/tabs/assets/tabs.css"),)
+
+debugger = (Script(type="module", src="./components/debugger/assets/Debugger.js"),)
+
+routes = (
+ Mount(LOGIN_ROUTE_ROOT, login_app, name="login"),
+ Mount(REGISTER_ROUTE_ROOT, register_app, name="register"),
+ Mount(THEME_CONTROLLER_ROUTE_ROOT, theme_controller_app, name="theme_controller"),
+ Mount(DRAWER_LAYOUT_ROUTE_ROOT, drawer_layout_app, name="main"),
+ Mount(ADD_STUFF_ROUTE_ROOT, add_stuff_app, name="add_stuff"),
+ Mount(TABS_ROUTE_ROOT, tabs_app, name="tabs"),
+ Mount(FORM_ROUTE_ROOT, form_app, name="form"),
+ Mount(DATAGRID_NEW_ROUTE_ROOT, datagrid_new_app, name="datagrid_new"),
+ Mount(DEBUGGER_ROUTE_ROOT, debugger_app, name="debugger"),
+
+ Mount(f"/{BASIC_TEST_PATH}", basic_test_app, name="basic test"),
+ Mount(f"/{DATAGRID_PATH}", datagrid_app, name="datagrid"),
+ Mount(f"/{IMPORT_SETTINGS_PATH}", import_settings_app, name="import_settings"),
+
+)
-def print_hi(name):
- # Use a breakpoint in the code line below to debug your script.
- print(f'Hi, {name}') # Press Ctrl+F8 to toggle the breakpoint.
+# The `before` function is a *Beforeware* function. These are functions that run before a route handler is called.
+def before(request, session):
+ # This sets the `auth` attribute in the request scope, and gets it from the session.
+ # The session is a Starlette session, which is a dict-like object which is cryptographically signed,
+ # so it can't be tampered with.
+ # The `auth` key in the scope is automatically provided to any handler which requests it, and can not
+ # be injected by the user using query params, cookies, etc, so it should be secure to use.
+
+ # auth = request.scope['auth'] = sess.session('auth', None)
+ if not AuthManager.is_authenticated(session):
+ return RedirectResponse(LOGIN_ROUTE_ROOT + LoginRoutes.Login, status_code=303)
-# Press the green button in the gutter to run the script.
-if __name__ == '__main__':
- print_hi('PyCharm')
+# To create a Beforeware object, we pass the function itself, and optionally a list of regexes to skip.
+bware = Beforeware(before, skip=[r'/favicon\.ico',
+ r'/static/.*',
+ r'.*\.css',
+ LOGIN_ROUTE_ROOT + LoginRoutes.Login,
+ LOGIN_ROUTE_ROOT + LoginRoutes.Logout,
+ LOGIN_ROUTE_ROOT + LoginRoutes.LoginByEmail,
+ REGISTER_ROUTE_ROOT + RegisterRoutes.Register,
+ REGISTER_ROUTE_ROOT + RegisterRoutes.RegisterByEmail, ])
-# See PyCharm help at https://www.jetbrains.com/help/pycharm/
+app, rt = fast_app(
+ before=bware,
+ hdrs=(daisy_ui_links,
+ main_links, my_managing_tools_style,
+ drawer_layout, addstuff,
+ tabs, debugger, datagrid),
+ live=True,
+ routes=routes,
+ debug=True,
+ pico=False,
+)
+
+settings_manager = SettingsManager()
+settings_manager.init_user(NO_SESSION, NOT_LOGGED)
+
+import_settings = AdminImportSettings(settings_manager, None)
+pages = [
+ Page("My table", get_datagrid, id="my_table"),
+ Page("new settings", import_settings, id="import_settings"),
+ Page("Basic test", get_basic_test, id="basic_test"),
+ Page("Restore state", testing_restore_state, id="testing_states"),
+ Page("Another Table", get_datagrid2, id="another_table"),
+]
+
+login = Login(settings_manager)
+register = Register(settings_manager)
+InstanceManager.register_many(login, register)
+
+
+@rt(Routes.Root)
+def get(session):
+ try:
+ main = InstanceManager.get(session,
+ DrawerLayout.create_component_id(session),
+ DrawerLayout,
+ settings_manager=settings_manager)
+ return page_layout_lite(session, settings_manager, main)
+ except DbException:
+ return RedirectResponse(LOGIN_ROUTE_ROOT + LoginRoutes.Logout, status_code=303)
+
+
+@rt(Routes.Logout)
+def get(session):
+ AuthManager.logout_user(session)
+ return RedirectResponse('/', status_code=303)
+
+
+@rt("/test")
+def get(session):
+ return (Title("Another Project Management"),
+ datagridOld, drw_layout_old, daisy_ui_links_v4,
+ Input(type='checkbox', value='light', cls='toggle theme-controller'),
+ DrawerLayoutOld(pages),)
+
+
+# Error Handling
+@app.get("/{path:path}")
+def not_found(path: str, session=None):
+ """Handler for 404 Not Found errors."""
+ error_content = Div(
+ H1("404 - Page Not Found", cls="text-3xl font-bold text-gray-800 mb-4"),
+ P(f"Sorry, the page '/{path}' does not exist.", cls="mb-4"),
+ A("Return Home", href="/",
+ cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
+ cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md text-center"
+ )
+
+ return page_layout_new(
+ session=session,
+ settings_manager=settings_manager,
+ content=error_content
+ )
+
+
+setup_toasts(app)
+
+
+@rt('/toasting')
+def get(session):
+ # Normally one toast is enough, this allows us to see
+ # different toast types in action.
+ add_toast(session, f"Toast is being cooked", "info")
+ add_toast(session, f"Toast is ready", "success")
+ add_toast(session, f"Toast is getting a bit crispy", "warning")
+ add_toast(session, f"Toast is burning!", "error")
+ return Titled("I like toast")
+
+
+serve(port=5001)
diff --git a/src/pages/__init__.py b/src/pages/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/pages/admin.py b/src/pages/admin.py
new file mode 100644
index 0000000..d02b143
--- /dev/null
+++ b/src/pages/admin.py
@@ -0,0 +1,210 @@
+from fasthtml.common import *
+
+from core.user_dao import UserDAO
+
+
+def admin_dashboard():
+ """
+ Create the admin dashboard page.
+
+ Returns:
+ Components representing the admin dashboard
+ """
+ return Div(
+ # Page header
+ H1("Admin Dashboard", cls="text-3xl font-bold text-gray-800 mb-6"),
+
+ # Admin menu
+ Div(
+ A(
+ Div(
+ Div(
+ "Users",
+ cls="text-xl font-semibold mb-2"
+ ),
+ P("Manage user accounts, set admin privileges", cls="text-sm text-gray-600"),
+ cls="p-4"
+ ),
+ href="/admin/users",
+ cls="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 mb-4"
+ ),
+
+ A(
+ Div(
+ Div(
+ "Title Generation History",
+ cls="text-xl font-semibold mb-2"
+ ),
+ P("View all users' title generation history", cls="text-sm text-gray-600"),
+ cls="p-4"
+ ),
+ href="/admin/history",
+ cls="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 mb-4"
+ ),
+
+ cls="max-w-2xl mx-auto"
+ )
+ )
+
+def admin_users_page(page=1, error_message=None, success_message=None):
+ """
+ Create the admin users management page.
+
+ Args:
+ page: Current page number
+ error_message: Optional error message
+ success_message: Optional success message
+
+ Returns:
+ Components representing the admin users page
+ """
+ # Get users with pagination
+ limit = 10
+ offset = (page - 1) * limit
+ users = UserDAO.get_all_users(limit=limit, offset=offset)
+
+ # Create message alert if needed
+ message_alert = None
+ if error_message:
+ message_alert = Div(
+ P(error_message, cls="text-sm"),
+ cls="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"
+ )
+ elif success_message:
+ message_alert = Div(
+ P(success_message, cls="text-sm"),
+ cls="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4"
+ )
+
+ # Create user rows
+ user_rows = []
+ for user in users:
+ # Format dates
+ created_at = user.get('created_at', 'N/A')
+ if created_at and created_at != 'N/A':
+ created_at = created_at.split('T')[0] # Simple date format
+
+ last_login = user.get('last_login', 'Never')
+ if last_login and last_login != 'Never':
+ last_login = last_login.split('T')[0] # Simple date format
+
+ # Create user row
+ user_rows.append(
+ Tr(
+ Td(user['username'], cls="px-6 py-4 whitespace-nowrap"),
+ Td(user['email'], cls="px-6 py-4 whitespace-nowrap"),
+ Td(
+ Span(
+ "GitHub" if user.get('is_github_user') else "Email",
+ cls=f"px-2 py-1 text-xs rounded-full {'bg-purple-200 text-purple-800' if user.get('is_github_user') else 'bg-blue-200 text-blue-800'}"
+ ),
+ cls="px-6 py-4 whitespace-nowrap"
+ ),
+ Td(created_at, cls="px-6 py-4 whitespace-nowrap"),
+ Td(last_login, cls="px-6 py-4 whitespace-nowrap"),
+ Td(
+ Span(
+ "Admin" if user.get('is_admin') else "User",
+ cls=f"px-2 py-1 text-xs rounded-full {'bg-red-200 text-red-800' if user.get('is_admin') else 'bg-gray-200 text-gray-800'}"
+ ),
+ cls="px-6 py-4 whitespace-nowrap"
+ ),
+ Td(
+ Div(
+ # Toggle admin status
+ Form(
+ Button(
+ "Remove Admin" if user.get('is_admin') else "Make Admin",
+ type="submit",
+ cls=f"{'bg-gray-500 hover:bg-gray-600' if user.get('is_admin') else 'bg-blue-500 hover:bg-blue-600'} text-white text-xs py-1 px-2 rounded mr-2"
+ ),
+ action=f"/admin/users/{user['id']}/{'remove-admin' if user.get('is_admin') else 'make-admin'}",
+ method="post",
+ cls="inline"
+ ),
+
+ # Delete user
+ Form(
+ Button(
+ "Delete",
+ type="submit",
+ cls="bg-red-500 hover:bg-red-600 text-white text-xs py-1 px-2 rounded"
+ ),
+ action=f"/admin/users/{user['id']}/delete",
+ method="post",
+ cls="inline"
+ ),
+
+ cls="flex"
+ ),
+ cls="px-6 py-4 whitespace-nowrap"
+ ),
+ cls="bg-white border-b"
+ )
+ )
+
+ # Build pagination controls
+ current_page = page
+ pagination = Div(
+ Div(
+ A("← Previous",
+ href=f"/admin/users?page={current_page - 1}" if current_page > 1 else "#",
+ cls=f"px-4 py-2 rounded {'bg-blue-600 text-white' if current_page > 1 else 'bg-gray-200 text-gray-500 cursor-default'}"),
+ Span(f"Page {current_page}",
+ cls="px-4 py-2"),
+ A("Next →",
+ href=f"/admin/users?page={current_page + 1}" if len(users) == limit else "#",
+ cls=f"px-4 py-2 rounded {'bg-blue-600 text-white' if len(users) == limit else 'bg-gray-200 text-gray-500 cursor-default'}"),
+ cls="flex items-center justify-center space-x-2"
+ ),
+ cls="mt-6"
+ )
+
+ return Div(
+ # Breadcrumb navigation
+ Div(
+ A("Admin Dashboard", href="/admin", cls="text-blue-600 hover:underline"),
+ Span(" / ", cls="text-gray-500"),
+ Span("Users", cls="font-semibold"),
+ cls="mb-4 text-sm"
+ ),
+
+ # Page header
+ H1("User Management", cls="text-3xl font-bold text-gray-800 mb-6"),
+
+ # Message alert
+ message_alert if message_alert else "",
+
+ # Users table
+ Div(
+ Table(
+ Thead(
+ Tr(
+ Th("Username", cls="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"),
+ Th("Email", cls="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"),
+ Th("Auth Type", cls="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"),
+ Th("Created", cls="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"),
+ Th("Last Login", cls="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"),
+ Th("Role", cls="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"),
+ Th("Actions", cls="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"),
+ cls="bg-gray-50"
+ )
+ ),
+ Tbody(
+ *user_rows if user_rows else [
+ Tr(
+ Td("No users found", colspan="7", cls="px-6 py-4 text-center text-gray-500 italic")
+ )
+ ]
+ ),
+ cls="min-w-full divide-y divide-gray-200"
+ ),
+ cls="bg-white shadow overflow-x-auto rounded-lg"
+ ),
+
+ # Pagination
+ pagination,
+
+ cls="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8"
+ )
+
diff --git a/src/pages/admin_import_settings.py b/src/pages/admin_import_settings.py
new file mode 100644
index 0000000..9b7074c
--- /dev/null
+++ b/src/pages/admin_import_settings.py
@@ -0,0 +1,220 @@
+import dataclasses
+import uuid
+from io import BytesIO
+
+import pandas as pd
+from fasthtml.components import *
+from fasthtml.fastapp import fast_app
+from starlette.datastructures import UploadFile
+
+from components.datagrid.DataGrid import DataGrid, DG_FILTER_INPUT, DG_TABLE_FOOTER, DG_COLUMNS, DG_DATATYPE_STRING, \
+ DG_DATATYPE_BOOL, DG_READ_ONLY
+from components.datagrid.constants import DG_ROWS_INDEXES
+from core.settings_management import SettingsManager
+from core.utils import make_html_id, make_column_id
+
+ID_PREFIX = "import_settings"
+
+import_settings_app, rt = fast_app()
+_instances = {}
+
+IMPORT_SETTINGS_PATH = "import-settings"
+
+
+@dataclasses.dataclass
+class ColumnDef:
+ index: int
+ title: str
+ header: str
+ position: str
+
+
+@dataclasses.dataclass
+class Config:
+ type: str = None
+ file_name: str = None
+ sheet_name: str = None
+ header_row: int = None
+ columns: list[ColumnDef] = dataclasses.field(default_factory=list)
+
+
+class AdminImportSettings:
+
+ def __new__(cls, *args, **kwargs):
+ id_to_use = f"{ID_PREFIX}-{make_html_id(kwargs.get('id', None))}"
+ if id_to_use in _instances:
+ return _instances[id_to_use]
+ return super().__new__(cls)
+
+ def __init__(self, settings_manager: SettingsManager, config: Config = None, /, id=None):
+ if not hasattr(self, "_initialized"):
+ self._initialized = True
+ self._id = f"{ID_PREFIX}-{make_html_id(id) if id else uuid.uuid4().hex}"
+ _instances[self._id] = self
+
+ self.settings_manager = settings_manager
+ self.config = config or Config()
+ self.grid_settings = {
+ DG_COLUMNS: {
+ "column_id": {
+ "index": 1,
+ "title": "Column Id",
+ "type": DG_DATATYPE_STRING,
+ DG_READ_ONLY: False
+ },
+ "column_header": {
+ "index": 2,
+ "title": "Column Header",
+ "type": DG_DATATYPE_STRING,
+ },
+ "is_amount": {
+ "index": 3,
+ "title": "Amount",
+ "type": DG_DATATYPE_BOOL,
+ DG_READ_ONLY: False
+ },
+ },
+ DG_FILTER_INPUT: False,
+ DG_TABLE_FOOTER: False,
+ # DG_COLUMNS_REORDERING: False,
+ DG_ROWS_INDEXES: True,
+ }
+ self.datagrid = DataGrid(grid_settings=self.grid_settings)
+ self.content = None
+ self.sheet_names = None
+
+ def redraw(self):
+ return (
+ self._make_grid_component(),
+ self._make_select_sheet_name_component(sheet_names=self.sheet_names, selected=self.config.sheet_name, oob=True),
+ self._make_header_row_selection(header=self.config.header_row, oob=True),
+ )
+
+ def _make_excel_upload_component(self, oob=False):
+ return Input(type='file',
+ name='file',
+ hx_post=f"{IMPORT_SETTINGS_PATH}/upload",
+ hx_target=f"#dg_{self._id}", # select sheet_name
+ hx_encoding='multipart/form-data',
+ hx_swap="outerHTML",
+ hx_vals=f'{{"_id": "{self._id}"}}',
+ id=f"fu_{self._id}", # fu stands for 'file upload'
+ hx_swap_oob='true' if oob else None,
+ cls="file-input file-input-bordered file-input-sm w-full",
+ )
+
+ def _make_select_sheet_name_component(self, sheet_names=None, selected=None, oob=False):
+ options = [Option("No file selected", selected=True, disabled=True)] if sheet_names is None else \
+ [Option(
+ name,
+ selected=True if name == selected else None,
+ ) for name in sheet_names]
+
+ return Select(
+ *options,
+ id=f"s_{self._id}",
+ name="sheet_name",
+ hx_post=f"{IMPORT_SETTINGS_PATH}/get-columns",
+ hx_include=f"#hr_{self._id}",
+ hx_target=f"#dg_{self._id}",
+ hx_vals=f'{{"_id": "{self._id}"}}',
+ hx_swap_oob='true' if oob else None,
+ cls="select select-bordered select-sm w-full"
+ )
+
+ def _make_header_row_selection(self, header=None, oob=False):
+ return Input(type="text",
+ name="header_row",
+ id=f"hr_{self._id}",
+ placeholder="Header row",
+ value=f"{header}",
+ hx_post=f"{IMPORT_SETTINGS_PATH}/get-columns",
+ hx_include=f"#s_{self._id}",
+ hx_target=f"#dg_{self._id}",
+ hx_vals=f'{{"_id": "{self._id}"}}',
+ hx_swap_oob='true' if oob else None,
+ cls="input input-bordered input-sm w-full",
+ )
+
+ def _make_grid_component(self, oob=False):
+ return Div(
+ self.datagrid,
+ id=f"dg_{self._id}",
+ hx_swap_oob='true' if oob else None,
+ )
+
+ def _make_use_columns_letters(self, oob=False):
+ return Label(
+ Span("Use columns letters", cls="label-text"),
+ Input(type="checkbox", cls="checkbox checkbox-sm", ),
+ cls="label cursor-pointer", )
+
+ def get_columns_def(self, columns):
+ res = []
+ _mapping = {
+ "Column Id": lambda c: make_column_id(str(c).strip()),
+ "Column Header": lambda c: c,
+ "Amount": lambda c: False,
+ }
+ for column in columns:
+ conf = {}
+ res.append(conf)
+ for col_id, col_conf in self.grid_settings[DG_COLUMNS].items():
+ col_title = col_conf["title"]
+ conf[col_title] = _mapping[col_title](column)
+ return res
+
+ def on_file_uploaded(self, file_name, content):
+ if content is None:
+ return None
+
+ self.content = content
+ excel_file = pd.ExcelFile(BytesIO(self.content))
+ sheet_names = excel_file.sheet_names
+ self.sheet_names = sheet_names
+ self.config.file_name = file_name
+ self.config.sheet_name = sheet_names[0]
+ self.config.header_row = 0
+ self.on_excel_conf_changed(self.config.sheet_name, self.config.header_row)
+
+ def on_excel_conf_changed(self, sheet_name, header):
+ self.config.sheet_name = sheet_name
+ self.config.header_row = header
+ if self.content is not None:
+ df = pd.read_excel(BytesIO(self.content), sheet_name=sheet_name, header=header)
+ columns_def = self.get_columns_def(df.columns)
+ new_content = pd.DataFrame(columns_def)
+ self.datagrid.import_dataframe(new_content, reset=False)
+
+ def __ft__(self):
+ return Div(
+ Div(self._make_excel_upload_component(), cls="col-span-2"),
+ Div(self._make_select_sheet_name_component(), cls="col-span-2"),
+ Div(self._make_header_row_selection(), cls="col-span-1"),
+ Div(self._make_use_columns_letters(), cls="col-span-1"),
+ cls="grid grid-cols-5 gap-2",
+ ), Div(self._make_grid_component())
+
+
+@rt("/upload")
+async def post(session, _id: str, file: UploadFile):
+ try:
+ instance = _instances[_id]
+ content = await file.read()
+
+ instance.on_file_uploaded(file.filename, content)
+ return instance.redraw()
+ except ValueError as error:
+ return Div(f"{error}", role="alert", cls="alert alert-error")
+
+
+@rt("/get-columns")
+def post(session, _id: str, sheet_name: str, header_row: str):
+ print(f"sheet_name={sheet_name}, header_row={header_row}")
+ try:
+ instance = _instances[_id]
+ header = int(header_row) if header_row is not None else 0
+ instance.on_excel_conf_changed(sheet_name, header)
+ return instance.redraw()
+ except Exception as error:
+ return Div(f"{error}", role="alert", cls="alert alert-error")
diff --git a/src/pages/another_grid.py b/src/pages/another_grid.py
new file mode 100644
index 0000000..d18d454
--- /dev/null
+++ b/src/pages/another_grid.py
@@ -0,0 +1,15 @@
+import pandas as pd
+
+from components.datagrid.DataGrid import DataGrid
+
+data = {
+ 'Name': ['Kodjo', 'Kokoe', 'Aba', 'Koffi'],
+ 'Age': [49, 51, 46, 51],
+ 'City': ['Rosny', 'Nangis', 'Rosny', 'Abidjan']
+}
+
+df = pd.DataFrame(data)
+
+
+def get_datagrid2():
+ return DataGrid(df, id="another grid")
diff --git a/src/pages/basic_test.py b/src/pages/basic_test.py
new file mode 100644
index 0000000..079e91a
--- /dev/null
+++ b/src/pages/basic_test.py
@@ -0,0 +1,98 @@
+from fasthtml.common import *
+
+from components.datagrid.icons import icon_filter_regular, icon_dismiss_regular
+
+basic_test_app, rt = fast_app()
+BASIC_TEST_PATH = "basic_test"
+
+# def get_basic_test(): return Div(P('Hello World!'), hx_get=f"{BASIC_TEST_PATH}/change", hx_swap="outerHTML")
+#
+#
+# @rt('/change')
+# def get(): return Div(P('Nice to be here!'), hx_get=f"{BASIC_TEST_PATH}/change_again", hx_swap="outerHTML")
+#
+#
+# @rt('/change_again')
+# def get(): return Div(P('I changed again'), hx_get=f"{BASIC_TEST_PATH}/", hx_swap="outerHTML")
+
+# def get_basic_test():
+# icons = {"up": icon_chevron_sort_up,
+# "down": icon_chevron_sort_down,
+# "sort": icon_chevron_sort,
+# "filter": icon_filter}
+# return Div(
+# Table(
+# Thead(),
+# Tbody(
+# *[
+# Tr(
+# Td(name), Td(Div(icon, cls="icon-24")),
+# ) for name, icon in icons.items()
+# ]
+# )
+# )
+# )
+#
+# def get_basic_test():
+# return Div("Get Some HTML, Including A Value in the Request", hx_post="/example", hx_vals='{"myVal": "My Value"}')
+
+after_request_attr = {"hx-on::after-request": f"console.log('after-request');"}
+
+self_id = 'my_id'
+
+
+def def_get_filter(oob=False):
+ def _inner_get_filter():
+ pass
+
+ return Div(
+ Label(Div(icon_filter_regular, cls="icon-24"),
+ Input(name='f',
+ placeholder="Filter...",
+ hx_post=f"/{BASIC_TEST_PATH}/filter",
+ hx_trigger="keyup changed delay:300ms",
+ hx_target=f"#tb_{self_id}",
+ hx_vals=f'{{"g_id": "{self_id}", "c_id":"{BASIC_TEST_PATH}"}}',
+ hx_swap_oob='true' if oob else None,
+ **after_request_attr,
+ ),
+ cls="input input-bordered input-sm flex items-center gap-2"
+ ),
+ id=f"f_{self_id}",
+ hx_swap_oob='true' if oob else None,
+ )
+
+
+def get_component(oob=False):
+ return Div(
+ def_get_filter(),
+ Div(icon_dismiss_regular,
+ cls="icon-24 my-auto icon-btn ml-2",
+ hx_post=f"{BASIC_TEST_PATH}/reset_filter",
+ hx_trigger="click",
+ hx_target=f"#tb_{self_id}",
+ hx_vals=f'{{"g_id": "{self_id}", "c_id":"{BASIC_TEST_PATH}"}}',
+ **after_request_attr),
+ cls="flex mb-2",
+ id=f"fa_{self_id}", # fa stands for 'filter all'
+ hx_swap_oob='true' if oob else None,
+ )
+
+
+def get_basic_test():
+ return get_component(), Div(id=f"tb_{self_id}")
+
+
+@rt(f"/filter")
+def post(f: str):
+ return Div(f)
+
+
+@rt(f"/reset_filter")
+def post():
+ res = (Div(f"You reset",
+ hx_swap_oob="true",
+ id=f"tb_{self_id}"
+ ),
+ def_get_filter(oob=True))
+ return res,
diff --git a/src/pages/home.py b/src/pages/home.py
new file mode 100644
index 0000000..686a52c
--- /dev/null
+++ b/src/pages/home.py
@@ -0,0 +1,134 @@
+from fasthtml.common import *
+import config
+from auth.auth_manager import AuthManager
+
+def home(session=None):
+ """
+ Defines the home page content.
+
+ Args:
+ session: The session object for auth status
+
+ Returns:
+ Components representing the home page content
+ """
+ # Check if user is authenticated
+ is_authenticated = AuthManager.is_authenticated(session) if session else False
+ is_admin = AuthManager.is_admin(session) if session else False
+ username = session.get("username", "") if session else ""
+
+ # Hero content varies based on authentication
+ if is_authenticated:
+ hero_content = Div(
+ H1(f"Welcome back, {username}!",
+ cls="text-4xl font-bold text-center text-gray-800 mb-4"),
+ P("Continue creating engaging titles for your content with AI assistance.",
+ cls="text-xl text-center text-gray-600 mb-6"),
+ Div(
+ A("Generate New Titles",
+ href="/title-generator",
+ cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-3"),
+ A("View My History",
+ href="/history",
+ cls="bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded"),
+ *([A("Admin Dashboard",
+ href="/admin",
+ cls="ml-3 bg-purple-600 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded")] if is_admin else []),
+ cls="flex justify-center flex-wrap gap-y-2"
+ ),
+ cls="py-12"
+ )
+ else:
+ hero_content = Div(
+ H1(config.APP_NAME,
+ cls="text-4xl font-bold text-center text-gray-800 mb-4"),
+ P("Create engaging titles for your content with AI assistance.",
+ cls="text-xl text-center text-gray-600 mb-6"),
+ Div(
+ A("Sign In",
+ href="/login",
+ cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-3"),
+ A("Register",
+ href="/register",
+ cls="bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded"),
+ cls="flex justify-center"
+ ),
+ cls="py-12"
+ )
+
+ return Div(
+ # Hero section with conditional content
+ hero_content,
+
+ # Features section
+ Div(
+ H2("Features", cls="text-3xl font-bold text-center mb-8"),
+ Div(
+ # Feature 1
+ Div(
+ H3("Platform-Specific", cls="text-xl font-semibold mb-2"),
+ P("Generate titles optimized for blogs, YouTube, social media, and more.",
+ cls="text-gray-600"),
+ cls="bg-white p-6 rounded-lg shadow-md"
+ ),
+ # Feature 2
+ Div(
+ H3("Multiple Styles", cls="text-xl font-semibold mb-2"),
+ P("Choose from professional, casual, clickbait, or informative styles.",
+ cls="text-gray-600"),
+ cls="bg-white p-6 rounded-lg shadow-md"
+ ),
+ # Feature 3
+ Div(
+ H3("AI-Powered", cls="text-xl font-semibold mb-2"),
+ P("Utilizes advanced AI models to craft engaging, relevant titles.",
+ cls="text-gray-600"),
+ cls="bg-white p-6 rounded-lg shadow-md"
+ ),
+ cls="grid grid-cols-1 md:grid-cols-3 gap-6"
+ ),
+ cls="py-8"
+ ),
+
+ # How it works section
+ Div(
+ H2("How It Works", cls="text-3xl font-bold text-center mb-8"),
+ Div(
+ # Step 1
+ Div(
+ Div(
+ "1",
+ cls="flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4"
+ ),
+ H3("Enter Your Topic", cls="text-xl font-semibold mb-2"),
+ P("Describe what your content is about in detail.",
+ cls="text-gray-600"),
+ cls="bg-white p-6 rounded-lg shadow-md"
+ ),
+ # Step 2
+ Div(
+ Div(
+ "2",
+ cls="flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4"
+ ),
+ H3("Choose Settings", cls="text-xl font-semibold mb-2"),
+ P("Select the platform and style that matches your needs.",
+ cls="text-gray-600"),
+ cls="bg-white p-6 rounded-lg shadow-md"
+ ),
+ # Step 3
+ Div(
+ Div(
+ "3",
+ cls="flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4"
+ ),
+ H3("Get Results", cls="text-xl font-semibold mb-2"),
+ P("Review multiple title options and choose your favorite.",
+ cls="text-gray-600"),
+ cls="bg-white p-6 rounded-lg shadow-md"
+ ),
+ cls="grid grid-cols-1 md:grid-cols-3 gap-6"
+ ),
+ cls="py-8"
+ )
+ )
\ No newline at end of file
diff --git a/src/pages/testing_datagrid.py b/src/pages/testing_datagrid.py
new file mode 100644
index 0000000..911a437
--- /dev/null
+++ b/src/pages/testing_datagrid.py
@@ -0,0 +1,67 @@
+import pandas as pd
+
+from components.datagrid.DataGrid import DataGrid, DG_COLUMNS, DG_READ_ONLY, DG_AGGREGATE_FILTERED_SUM, VISIBLE_KEY
+from components.datagrid.constants import DG_ROWS_INDEXES
+
+data = {
+ 'Name': ['Alice', 'Bob', 'Charlie', 'David', "aaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
+ 'Age': [25, 30, 35, 40, 50],
+ 'City': ['New York', 'Los Angeles', 'Chicago', 'Houston', "bbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
+ 'Boolean': [True, False, False, True, False],
+ 'Choice': ["Bool", "Number", "Amount", None, "Number"]
+}
+
+df = pd.DataFrame(data)
+
+grid_settings = {
+ # DG_READ_ONLY: False,
+ # DG_SELECTION_MODE: DG_SELECTION_MODE_ROW,
+ # DG_FILTER_INPUT: False,
+ # DG_TABLE_HEADER: False,
+ DG_ROWS_INDEXES: True,
+ DG_COLUMNS: {
+ "name": {
+ "index": 0,
+ "title": "Name",
+ # "type": "list",
+ "values": ["Alice", "Bob", "Charlie", "David"],
+ DG_READ_ONLY: False,
+ # "width": "150px",
+ },
+ "age": {
+ "index": 1,
+ "title": "Age",
+ "type": "number",
+ # "width": "100px",
+ VISIBLE_KEY: False,
+ "agg_func": DG_AGGREGATE_FILTERED_SUM,
+ # "agg_func_2": DG_AGGREGATE_SUM,
+ },
+ "city": {
+ "index": 2,
+ "title": "City",
+ "type": "list",
+ "values": ["New York", "Los Angeles", "Chicago", "Houston"],
+ DG_READ_ONLY: False,
+ },
+ "boolean": {
+ "index": 4,
+ "title": "Boolean",
+ "type": "bool",
+ # "width": "100px",
+ DG_READ_ONLY: False,
+ },
+ "choice": {
+ "index": 3,
+ "title": "Choice",
+ "type": "choice",
+ "values": ["Bool", "Number", "Amount"],
+ DG_READ_ONLY: False,
+ },
+ }
+}
+
+
+def get_datagrid():
+ dg = DataGrid(df, grid_settings=grid_settings, id="testing_datagrid0")
+ return dg.mk_excel_upload_component(), dg
diff --git a/src/pages/testing_restore_state.py b/src/pages/testing_restore_state.py
new file mode 100644
index 0000000..b32aef5
--- /dev/null
+++ b/src/pages/testing_restore_state.py
@@ -0,0 +1,16 @@
+from fasthtml.common import *
+
+
+def testing_restore_state():
+ return Div(
+ Input(name='f', id=f"input_with_id", placeholder="Input with id"),
+ Input(name='f', placeholder="Input without id"),
+
+ Input(type='checkbox', id="checkbox_with_id", checked='checked', cls='checkbox'),
+ Input(type='checkbox', checked='checked', cls='checkbox'),
+
+ Input(type='radio', id="radio_with_id_1", name='radio-1', cls='radio'),
+ Input(type='radio', id="radio_with_id_2", name='radio-1', cls='radio'),
+ Input(type='radio', id="radio_with_id_3", name='radio-1', cls='radio'),
+
+ )
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..5dd9809
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,66 @@
+from io import BytesIO
+
+import pandas as pd
+import pytest
+
+from components.datagrid.DataGrid import reset_instances
+
+USER_EMAIL = "test@mail.com"
+USER_ID = "test_user"
+
+
+@pytest.fixture
+def excel_file_content():
+ # Create a simple Excel file in memory
+ df = pd.DataFrame({
+ 'Column 1': ['Aba', 'Johan', 'Kodjo'],
+ 'Column 2': ['Female', 'Male', 'Male']
+ })
+ excel_io = BytesIO()
+ df.to_excel(excel_io, index=False)
+ excel_io.seek(0)
+ return excel_io.read()
+
+
+@pytest.fixture
+def excel_file_content_2():
+ # Create a simple Excel file in memory
+ df = pd.DataFrame({
+ 'Column 1': ['C', 'A', 'B'],
+ 'Column 2': [1, 2, 3]
+ })
+ excel_io = BytesIO()
+ df.to_excel(excel_io, index=False)
+ excel_io.seek(0)
+ return excel_io.read()
+
+
+@pytest.fixture
+def excel_file_content_with_sheet_name():
+ # Create a DataFrame
+ df = pd.DataFrame({
+ 'Column 1 ': ['Aba', 'Johan', 'Kodjo'],
+ 'Column 2': [False, True, True],
+ 'Column 3 ': [10, 20, 30],
+ })
+
+ # Create an in-memory bytes buffer
+ excel_io = BytesIO()
+
+ # Write the dataframe to the buffer with a custom sheet name
+ df.to_excel(excel_io, index=False, sheet_name="sheet_name")
+
+ # Move the pointer to the start of the stream
+ excel_io.seek(0)
+
+ return excel_io.read() # Return the binary data
+
+
+@pytest.fixture(autouse=True)
+def reset_datagrid_instances():
+ reset_instances()
+
+
+@pytest.fixture
+def session():
+ return {"user_id": USER_ID, "user_email": USER_EMAIL}
diff --git a/tests/fixtures/Book1.xlsx b/tests/fixtures/Book1.xlsx
new file mode 100644
index 0000000..2caa681
Binary files /dev/null and b/tests/fixtures/Book1.xlsx differ
diff --git a/tests/helpers.py b/tests/helpers.py
new file mode 100644
index 0000000..3312356
--- /dev/null
+++ b/tests/helpers.py
@@ -0,0 +1,549 @@
+import dataclasses
+import json
+import re
+from collections import OrderedDict
+
+import numpy
+import pandas as pd
+from bs4 import BeautifulSoup
+from fastcore.basics import NotStr
+from fastcore.xml import to_xml
+from fasthtml.components import html2ft, Div
+
+pattern = r"""(?P