- Added EveAI Client to project
- Improvements to EntitlementsDomain & Services - Prechecks in Document domain - Add audit information to LicenseUsage
This commit is contained in:
56
eveai_client/platform/templates/base.html
Normal file
56
eveai_client/platform/templates/base.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<html lang="en" itemscope itemtype="http://schema.org/WebPage">
|
||||
{% include 'head.html' %}
|
||||
|
||||
<body>
|
||||
<!-- Overall Layout -------------------------------------------------------------------------->
|
||||
<div class="wrapper">
|
||||
<!-- Top Navigation Bar ----------------------------->
|
||||
<header class="navbar navbar-expand-lg navbar-light bg-white">
|
||||
<div class="container-fluid">
|
||||
<h1 class="navbar-brand mb-0">EveAI Client</h1>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.index') }}">
|
||||
<i class="material-icons">home</i> Home
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Main container Layout -------------------------->
|
||||
<main class="container-fluid mt-2 mb-4 px-3 console-expanded">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar ----------------------------------------->
|
||||
<div class="status-bar fixed-bottom w-100 bg-light border-top">
|
||||
<div class="container-fluid p-0">
|
||||
<!-- Toggle button for console -->
|
||||
<div class="d-flex justify-content-between align-items-center p-1">
|
||||
<button id="toggleConsole" class="btn btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#consoleOutput" aria-expanded="true" aria-controls="consoleOutput">
|
||||
<i class="material-icons" style="font-size: 16px;">expand_more</i>
|
||||
</button>
|
||||
<span id="currentStatus">Ready</span>
|
||||
<span id="processingStatus" class="badge bg-ready">Ready</span>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible console log area - show by default -->
|
||||
<div id="consoleOutput" class="console-output collapse show">
|
||||
<div id="statusMessages" class="status-messages p-2">
|
||||
<div class="log-entries">
|
||||
<!-- Initial message will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
{% include 'scripts.html' %}
|
||||
</html>
|
||||
25
eveai_client/platform/templates/error.html
Normal file
25
eveai_client/platform/templates/error.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}EveAI - Error{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header p-3 bg-danger">
|
||||
<h2 class="card-title text-white mb-0">Error</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-danger mb-4">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<button onclick="window.history.back()" class="btn btn-secondary">Back</button>
|
||||
<a href="{{ url_for('main.index') }}" class="btn btn-primary">Home</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
0
eveai_client/platform/templates/footer.html
Normal file
0
eveai_client/platform/templates/footer.html
Normal file
34
eveai_client/platform/templates/head.html
Normal file
34
eveai_client/platform/templates/head.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="{{url_for('static', filename='assets/img/apple-icon.png')}}">
|
||||
<link rel="icon" type="image/png" href="{{url_for('static', filename='assets/img/favicon.png')}}">
|
||||
<title>
|
||||
{% block title %}EveAI Client{% endblock %}
|
||||
</title>
|
||||
<!-- Fonts and icons -->
|
||||
<link rel="stylesheet" type="text/css" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700,900|Roboto+Slab:400,700" />
|
||||
<!-- Nucleo Icons -->
|
||||
<link href="{{url_for('static', filename='assets/css/nucleo-icons.css" rel="stylesheet')}}" />
|
||||
<link href="{{url_for('static', filename='assets/css/nucleo-svg.css" rel="stylesheet')}}" />
|
||||
<!-- Font Awesome Icons -->
|
||||
<script src="https://kit.fontawesome.com/42d5adcbca.js" crossorigin="anonymous"></script>
|
||||
<!-- Material Icons -->
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet" />
|
||||
<!-- CSS Files -->
|
||||
<link id="pagestyle" href="{{url_for('static', filename='assets/css/material-kit-pro.css')}}" rel="stylesheet" />
|
||||
<link id="pagestyle" href="{{url_for('static', filename='assets/css/eveai.css')}}" rel="stylesheet" />
|
||||
<!-- HTMX Scripts -->
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script>
|
||||
// Initialize a basic logViewer placeholder that will collect messages until the real one is ready
|
||||
window.logViewerQueue = [];
|
||||
window.logViewer = {
|
||||
info: function(msg) { window.logViewerQueue.push({type: 'info', message: msg}); },
|
||||
error: function(msg) { window.logViewerQueue.push({type: 'error', message: msg}); },
|
||||
warning: function(msg) { window.logViewerQueue.push({type: 'warning', message: msg}); },
|
||||
success: function(msg) { window.logViewerQueue.push({type: 'success', message: msg}); }
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
433
eveai_client/platform/templates/macros.html
Normal file
433
eveai_client/platform/templates/macros.html
Normal file
@@ -0,0 +1,433 @@
|
||||
{% macro render_field_content(field, disabled=False, class='') %}
|
||||
{% if field.type == 'BooleanField' %}
|
||||
<div class="form-group">
|
||||
<div class="form-check form-switch">
|
||||
{{ field(class="form-check-input " + class, disabled=disabled) }}
|
||||
{% if field.description %}
|
||||
{{ field.label(class="form-check-label",
|
||||
**{'data-bs-toggle': 'tooltip',
|
||||
'data-bs-placement': 'right',
|
||||
'title': field.description}) }}
|
||||
{% if field.flags.required %}
|
||||
<span class="required-field-indicator" aria-hidden="true">
|
||||
<i class="material-symbols-outlined required-icon">check_circle</i>
|
||||
</span>
|
||||
<span class="visually-hidden">Required field</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ field.label(class="form-check-label") }}
|
||||
{% if field.flags.required %}
|
||||
<span class="required-field-indicator" aria-hidden="true">
|
||||
<i class="material-symbols-outlined required-icon">check_circle</i>
|
||||
</span>
|
||||
<span class="visually-hidden">Required field</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in field.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group">
|
||||
{% if field.description %}
|
||||
{{ field.label(class="form-label",
|
||||
**{'data-bs-toggle': 'tooltip',
|
||||
'data-bs-placement': 'right',
|
||||
'title': field.description}) }}
|
||||
{% if field.flags.required %}
|
||||
<span class="required-field-indicator" aria-hidden="true">
|
||||
<i class="material-symbols-outlined required-icon">check_circle</i>
|
||||
</span>
|
||||
<span class="visually-hidden">Required field</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ field.label(class="form-label") }}
|
||||
{% if field.flags.required %}
|
||||
<span class="required-field-indicator" aria-hidden="true">
|
||||
<i class="material-symbols-outlined required-icon">check_circle</i>
|
||||
</span>
|
||||
<span class="visually-hidden">Required field</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if field.type == 'TextAreaField' and 'json-editor' in class %}
|
||||
<div id="{{ field.id }}-editor" class="json-editor-container"></div>
|
||||
{{ field(class="form-control d-none " + class, disabled=disabled) }}
|
||||
{% elif field.type == 'SelectField' %}
|
||||
{{ field(class="form-control form-select " + class, disabled=disabled) }}
|
||||
{% else %}
|
||||
{{ field(class="form-control " + class, disabled=disabled) }}
|
||||
{% endif %}
|
||||
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in field.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro render_field(field, disabled_fields=[], exclude_fields=[], class='') %}
|
||||
<!-- Debug info -->
|
||||
<!-- Field name: {{ field.name }}, Field type: {{ field.__class__.__name__ }} -->
|
||||
|
||||
{% set disabled = field.name in disabled_fields %}
|
||||
{% set exclude_fields = exclude_fields + ['csrf_token', 'submit'] %}
|
||||
{% if field.name not in exclude_fields %}
|
||||
{{ render_field_content(field, disabled, class) }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_included_field(field, disabled_fields=[], include_fields=[], class='') %}
|
||||
{% set disabled = field.name in disabled_fields %}
|
||||
{% if field.name in include_fields %}
|
||||
{{ render_field_content(field, disabled, class) }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_table(headers, rows) %}
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-items-center mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for header in headers %}
|
||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7">{{ header }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td class="{{ cell.class }}">
|
||||
{% if cell.type == 'image' %}
|
||||
<div class="d-flex px-2 py-1">
|
||||
<div>
|
||||
<img src="{{ cell.value }}" class="avatar avatar-sm me-3">
|
||||
</div>
|
||||
</div>
|
||||
{% elif cell.type == 'text' %}
|
||||
<p class="text-xs {{ cell.text_class }}">{{ cell.value }}</p>
|
||||
{% elif cell.type == 'badge' %}
|
||||
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
||||
{% elif cell.type == 'link' %}
|
||||
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
||||
{% else %}
|
||||
{{ cell.value }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_selectable_table(headers, rows, selectable, id, is_component_selector=False) %}
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-items-center mb-0" id="{{ id }}">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if selectable %}
|
||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7">Select</th>
|
||||
{% endif %}
|
||||
{% for header in headers %}
|
||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7">{{ header }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% if selectable %}
|
||||
<td>
|
||||
<input type="radio"
|
||||
name="selected_row"
|
||||
value="{{ row[0] }}"
|
||||
{% if is_component_selector %}
|
||||
data-component-selector="true"
|
||||
{% else %}
|
||||
required
|
||||
{% endif %}>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% for cell in row %}
|
||||
<td class="{{ cell.class }}">
|
||||
{% if cell.type == 'image' %}
|
||||
<div class="d-flex px-2 py-1">
|
||||
<div>
|
||||
<img src="{{ cell.value }}" class="avatar avatar-sm me-3">
|
||||
</div>
|
||||
</div>
|
||||
{% elif cell.type == 'text' %}
|
||||
<p class="text-xs {{ cell.text_class }}">{{ cell.value }}</p>
|
||||
{% elif cell.type == 'badge' %}
|
||||
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
||||
{% elif cell.type == 'link' %}
|
||||
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
||||
{% else %}
|
||||
{{ cell.value }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_selectable_sortable_table(headers, rows, selectable, id, sort_by, sort_order) %}
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-items-center mb-0" id="{{ id }}">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if selectable %}
|
||||
<th>Select</th>
|
||||
{% endif %}
|
||||
{% for header in headers %}
|
||||
<th class="sortable" data-sort="{{ header|lower|replace(' ', '_') }}">
|
||||
{{ header }}
|
||||
{% if sort_by == header|lower|replace(' ', '_') %}
|
||||
{% if sort_order == 'asc' %}
|
||||
<i class="fas fa-sort-up"></i>
|
||||
{% elif sort_order == 'desc' %}
|
||||
<i class="fas fa-sort-down"></i>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<i class="fas fa-sort"></i>
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% if selectable %}
|
||||
<td><input type="radio" name="selected_row" value="{{ row[0].value }}"></td>
|
||||
{% endif %}
|
||||
{% for cell in row %}
|
||||
<td>{{ cell.value }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_selectable_sortable_table_with_dict_headers(headers, rows, selectable, id, sort_by, sort_order) %}
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-items-center mb-0" id="{{ id }}">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if selectable %}
|
||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7">Select</th>
|
||||
{% endif %}
|
||||
{% for header in headers %}
|
||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 sortable" data-sort="{{ header['sort'] }}">
|
||||
{{ header['text'] }}
|
||||
{% if sort_by == header['sort'] %}
|
||||
{% if sort_order == 'asc' %}
|
||||
<i class="fas fa-sort-up"></i>
|
||||
{% elif sort_order == 'desc' %}
|
||||
<i class="fas fa-sort-down"></i>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<i class="fas fa-sort"></i>
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% if selectable %}
|
||||
<td><input type="radio" name="selected_row" value="{{ row[0].value }}"></td>
|
||||
{% endif %}
|
||||
{% for cell in row %}
|
||||
<td>{{ cell.value }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_accordion(accordion_id, accordion_items, header_title, header_description) %}
|
||||
<div class="accordion-1">
|
||||
<div class="container">
|
||||
<div class="row my-5">
|
||||
<div class="col-md-6 mx-auto text-center">
|
||||
<h2>{{ header_title }}</h2>
|
||||
<p>{{ header_description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-10 mx-auto">
|
||||
<div class="accordion" id="{{ accordion_id }}">
|
||||
{% for item in accordion_items %}
|
||||
<div class="accordion-item mb-3">
|
||||
<h5 class="accordion-header" id="heading{{ loop.index }}">
|
||||
<button class="accordion-button border-bottom font-weight-bold collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ loop.index }}" aria-expanded="false" aria-controls="collapse{{ loop.index }}">
|
||||
{{ item.title }}
|
||||
<i class="collapse-close fa fa-plus text-xs pt-1 position-absolute end-0 me-3" aria-hidden="true"></i>
|
||||
<i class="collapse-open fa fa-minus text-xs pt-1 position-absolute end-0 me-3" aria-hidden="true"></i>
|
||||
</button>
|
||||
</h5>
|
||||
<div id="collapse{{ loop.index }}" class="accordion-collapse collapse" aria-labelledby="heading{{ loop.index }}" data-bs-parent="#{{ accordion_id }}">
|
||||
<div class="accordion-body text-sm opacity-8">
|
||||
{{ item.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_nested_table(headers, rows) %}
|
||||
<div class="">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-items-center mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for header in headers %}
|
||||
<th class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7">{{ header }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
{% if cell.is_group %}
|
||||
<td colspan="{{ cell.colspan }}" class="{{ cell.class }}">
|
||||
{{ render_nested_table(cell.headers, cell.sub_rows) }}
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="{{ cell.class }}">
|
||||
{% if cell.type == 'image' %}
|
||||
<div class="d-flex px-2 py-1">
|
||||
<div>
|
||||
<img src="{{ cell.value }}" class="avatar avatar-sm me-3">
|
||||
</div>
|
||||
</div>
|
||||
{% elif cell.type == 'text' %}
|
||||
<p class="text-xs {{ cell.text_class }}">{{ cell.value }}</p>
|
||||
{% elif cell.type == 'badge' %}
|
||||
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
|
||||
{% elif cell.type == 'link' %}
|
||||
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
|
||||
{% else %}
|
||||
{{ cell.value }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_pagination(pagination, endpoint) %}
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination">
|
||||
<!-- Previous Button -->
|
||||
<li class="page-item {{ 'disabled' if not pagination.has_prev }}">
|
||||
<a class="page-link" href="{{ url_for(endpoint, page=pagination.prev_num) if pagination.has_prev else 'javascript:;' }}" tabindex="-1">
|
||||
<span class="material-symbols-outlined">keyboard_double_arrow_left</span>
|
||||
{# <span class="sr-only">Previous</span>#}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Page Number Buttons -->
|
||||
{% for page in pagination.iter_pages(left_edge=1, left_current=2, right_current=3, right_edge=1) %}
|
||||
<li class="page-item {{ 'active' if page == pagination.page }}">
|
||||
<a class="page-link" href="{{ url_for(endpoint, page=page) }}">
|
||||
{% if page == pagination.page %}
|
||||
<span class="material-symbols-outlined">target</span>
|
||||
{# <span class="sr-only">(current)</span>#}
|
||||
{% else %}
|
||||
{{ page }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Next Button -->
|
||||
<li class="page-item {{ 'disabled' if not pagination.has_next }}">
|
||||
<a class="page-link" href="{{ url_for(endpoint, page=pagination.next_num) if pagination.has_next else 'javascript:;' }}">
|
||||
<span class="material-symbols-outlined">keyboard_double_arrow_right</span>
|
||||
{# <span class="sr-only">Next</span>#}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_filter_field(field_name, label, options, current_value) %}
|
||||
<div class="form-group">
|
||||
<label for="{{ field_name }}">{{ label }}</label>
|
||||
<select class="form-control" id="{{ field_name }}" name="{{ field_name }}">
|
||||
<option value="">All</option>
|
||||
{% for value, text in options %}
|
||||
<option value="{{ value }}" {% if value == current_value %}selected{% endif %}>{{ text }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_date_filter_field(field_name, label, current_value) %}
|
||||
<div class="form-group">
|
||||
<label for="{{ field_name }}">{{ label }}</label>
|
||||
<input type="date" class="form-control" id="{{ field_name }}" name="{{ field_name }}" value="{{ current_value }}">
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_collapsible_section(id, title, content) %}
|
||||
<div class="accordion" id="accordion{{ id }}">
|
||||
<div class="accordion-item mb-3">
|
||||
<h5 class="accordion-header" id="heading{{ id }}">
|
||||
<button class="accordion-button border-bottom font-weight-bold collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ id }}" aria-expanded="false" aria-controls="collapse{{ id }}">
|
||||
{{ title }}
|
||||
<i class="collapse-close fa fa-plus text-xs pt-1 position-absolute end-0 me-3" aria-hidden="true"></i>
|
||||
<i class="collapse-open fa fa-minus text-xs pt-1 position-absolute end-0 me-3" aria-hidden="true"></i>
|
||||
</button>
|
||||
</h5>
|
||||
<div id="collapse{{ id }}" class="accordion-collapse collapse" aria-labelledby="heading{{ id }}" data-bs-parent="#accordion{{ id }}">
|
||||
<div class="accordion-body text-sm opacity-8">
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!-- partials/message_exchange.html -->
|
||||
<!-- User Message -->
|
||||
<div class="message-container user-container">
|
||||
<div class="message-bubble user-message">{{ query }}</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Response -->
|
||||
<div class="message-container ai-container" id="response-{{ task_id }}">
|
||||
<div class="message-bubble ai-message">
|
||||
<div class="typing-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
57
eveai_client/platform/templates/scripts.html
Normal file
57
eveai_client/platform/templates/scripts.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<!-- Optional JavaScript -->
|
||||
|
||||
<!-- Public scripts ------------------------------------------------------------------------------>
|
||||
<!-- jQuery first, then Popper.js, then Bootstrap JS -->
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.10.21/js/jquery.dataTables.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/perfect-scrollbar.min.js"></script>
|
||||
<script src="{{url_for('static', filename='assets/js/plugins/typedjs.js')}}"></script>
|
||||
<script src="{{url_for('static', filename='assets/js/plugins/prism.min.js')}}"></script>
|
||||
<script src="{{url_for('static', filename='assets/js/plugins/highlight.min.js')}}"></script>
|
||||
<script src="{{url_for('static', filename='assets/js/plugins/parallax.min.js')}}"></script>
|
||||
<script src="{{url_for('static', filename='assets/js/plugins/nouislider.min.js')}}"></script>
|
||||
<script src="{{url_for('static', filename='assets/js/plugins/anime.min.js')}}"></script>
|
||||
<script src="{{url_for('static', filename='assets/js/material-kit-pro.min.js')}}?v=3.0.4 type="text/javascript"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/jsoneditor/10.1.0/jsoneditor.min.css" rel="stylesheet" type="text/css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsoneditor/10.1.0/jsoneditor.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"></script>
|
||||
|
||||
<!-- Custom scripts ------------------------------------------------------------------------------>
|
||||
<script src="{{url_for('static', filename='assets/js/console.js')}}"></script>
|
||||
<script src="{{url_for('static', filename='assets/js/processing-status.js')}}"></script>
|
||||
|
||||
<!-- Marked Component ---------------------------------------------------------------------------->
|
||||
<script>
|
||||
// Configure Marked.js options
|
||||
marked.setOptions({
|
||||
gfm: true, // GitHub flavored markdown
|
||||
breaks: true, // Interpret line breaks as <br>
|
||||
headerIds: true, // Add ids to headings
|
||||
mangle: false, // Don't escape HTML
|
||||
pedantic: false, // Don't be overly conformant to original markdown
|
||||
sanitize: false, // Don't sanitize HTML (use DOMPurify instead for security)
|
||||
smartLists: true, // Use smarter list behavior
|
||||
smartypants: true, // Use smart typography (quotes, dashes)
|
||||
xhtml: false // Don't close single tags with />
|
||||
});
|
||||
|
||||
// Function to safely convert markdown to HTML
|
||||
function renderMarkdown(markdownText) {
|
||||
if (typeof markdownText === 'string') {
|
||||
try {
|
||||
// Convert markdown to HTML, then sanitize it
|
||||
const rawHtml = marked.parse(markdownText);
|
||||
return DOMPurify.sanitize(rawHtml);
|
||||
} catch (error) {
|
||||
console.error('Error parsing markdown:', error);
|
||||
return markdownText;
|
||||
}
|
||||
}
|
||||
return markdownText;
|
||||
}
|
||||
</script>
|
||||
392
eveai_client/platform/templates/specialist_form_chat.html
Normal file
392
eveai_client/platform/templates/specialist_form_chat.html
Normal file
@@ -0,0 +1,392 @@
|
||||
<div class="card border-0">
|
||||
<div class="d-flex flex-column h-100 border-0 overflow-hidden specialist-container">
|
||||
<!-- Configuration area - auto-sized based on content -->
|
||||
<div class="specialist-arguments p-3 border-bottom config-section">
|
||||
<h5 class="mb-3">Configuration</h5>
|
||||
<form id="specialistConfigForm">
|
||||
<input type="hidden" name="specialist_id" value="{{ specialist_id }}">
|
||||
<div class="row">
|
||||
{% for arg_name, arg_config in config.items() %}
|
||||
{% if arg_name|lower != 'query' %}
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="{{ arg_name }}" class="form-label">
|
||||
{{ arg_name }}
|
||||
{% if arg_config.required %}
|
||||
<span class="text-danger">*</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
|
||||
{% if arg_config.type == 'bool' or arg_config.type == 'checkbox' %}
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="{{ arg_name }}" name="{{ arg_name }}"
|
||||
class="form-check-input" value="true">
|
||||
</div>
|
||||
{% elif arg_config.type == 'select' and arg_config.options %}
|
||||
<select id="{{ arg_name }}" name="{{ arg_name }}" class="form-select form-control">
|
||||
{% for option in arg_config.options %}
|
||||
<option value="{{ option.value }}">{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="text" id="{{ arg_name }}" name="{{ arg_name }}"
|
||||
class="form-control"
|
||||
{% if arg_config.required %}required{% endif %}
|
||||
placeholder="{{ arg_config.description }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Chat Interface - takes remaining height -->
|
||||
<div class="chat-interface d-flex flex-column flex-grow-1 overflow-hidden">
|
||||
<!-- Chat History - grows to fill available space -->
|
||||
<div class="message-history p-3 flex-grow-1 overflow-auto" id="messageHistory">
|
||||
<!-- Welcome message -->
|
||||
<div class="message-container ai-container">
|
||||
<div class="message-bubble ai-message markdown-content">
|
||||
Hello! I'm {{ specialist.name }}. How can I help you today?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Input - fixed height at bottom -->
|
||||
<div class="message-input p-3 border-top message-input-section">
|
||||
<div class="input-group">
|
||||
<textarea class="form-control" id="queryInput"
|
||||
placeholder="Type your message here... (Shift+Enter for new line)"
|
||||
rows="2"></textarea>
|
||||
<button class="btn btn-primary" id="sendButton" type="button">
|
||||
<i class="material-icons">send</i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden fields for data -->
|
||||
<div id="hiddenData" style="display:none;">
|
||||
<input type="hidden" id="specialistIdField" value="{{ specialist_id }}">
|
||||
<input type="hidden" id="sessionIdField" value="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline script directly after the relevant HTML -->
|
||||
<script type="text/javascript">
|
||||
// Use an immediately invoked function to avoid global scope issues
|
||||
(function() {
|
||||
console.log('Script starting execution');
|
||||
|
||||
function setupSSEConnection(taskId) {
|
||||
console.log(`Setting up SSE connection for task ${taskId}`);
|
||||
|
||||
// Close any previous connections
|
||||
if (window.activeEventSource) {
|
||||
console.log('Closing previous SSE connection');
|
||||
window.activeEventSource.close();
|
||||
}
|
||||
|
||||
// Create new EventSource
|
||||
const evtSource = new EventSource(`/specialist/process/${taskId}`);
|
||||
window.activeEventSource = evtSource;
|
||||
|
||||
evtSource.onopen = function() {
|
||||
console.log(`SSE connection opened for task ${taskId}`);
|
||||
};
|
||||
|
||||
evtSource.onmessage = function(event) {
|
||||
console.log('SSE message received:', event.data);
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Debug log viewer state
|
||||
console.log('LogViewer state:', window.logViewer ? 'exists' : 'undefined');
|
||||
|
||||
// Update log viewer
|
||||
if (window.logViewer) {
|
||||
try {
|
||||
if (data.type && typeof window.logViewer[data.type] === 'function') {
|
||||
console.log(`Calling logViewer.${data.type} with message:`, data.message || JSON.stringify(data));
|
||||
window.logViewer[data.type](data.message || JSON.stringify(data));
|
||||
window.processingStatus.updateStatus("Processing", data.type)
|
||||
} else {
|
||||
console.log('Calling logViewer.info with message:', data.message || JSON.stringify(data));
|
||||
window.logViewer.info(data.message || JSON.stringify(data));
|
||||
window.processingStatus.processing('Processing')
|
||||
}
|
||||
} catch (logError) {
|
||||
console.error('Error updating logViewer:', logError);
|
||||
}
|
||||
} else {
|
||||
console.warn('LogViewer not available for message:', data);
|
||||
}
|
||||
|
||||
// Update the AI response bubble with the content when complete
|
||||
if (data.complete && data.content) {
|
||||
const responseElement = document.getElementById(`response-${taskId}`);
|
||||
if (responseElement) {
|
||||
const messageBubble = responseElement.querySelector('.ai-message');
|
||||
if (messageBubble) {
|
||||
// Use Markdown rendering to format the content
|
||||
messageBubble.innerHTML = renderMarkdown(data.content);
|
||||
messageBubble.classList.add('markdown-content');
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to the bottom
|
||||
messageHistory.scrollTop = messageHistory.scrollHeight;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing SSE message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
evtSource.onerror = function(error) {
|
||||
console.error('SSE connection error:', error);
|
||||
|
||||
// Update log viewer
|
||||
if (window.logViewer && typeof window.logViewer.error === 'function') {
|
||||
window.logViewer.error('Connection error with specialist stream');
|
||||
window.processingStatus.error('Disconnected')
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (window.activeEventSource === evtSource) {
|
||||
evtSource.close();
|
||||
window.activeEventSource = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up when page changes
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window.activeEventSource === evtSource) {
|
||||
evtSource.close();
|
||||
window.activeEventSource = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Basic function to make sure the script is running
|
||||
function initializeUI() {
|
||||
console.log('Initializing UI');
|
||||
|
||||
// Get references to DOM elements
|
||||
var queryInput = document.getElementById('queryInput');
|
||||
var sendButton = document.getElementById('sendButton');
|
||||
var messageHistory = document.getElementById('messageHistory');
|
||||
var specialistId = document.getElementById('specialistIdField').value;
|
||||
var sessionIdField = document.getElementById('sessionIdField');
|
||||
|
||||
// Debug element existence
|
||||
console.log('Query input found:', !!queryInput);
|
||||
console.log('Send button found:', !!sendButton);
|
||||
console.log('Message history found:', !!messageHistory);
|
||||
console.log('Specialist ID:', specialistId);
|
||||
|
||||
console.log('LogViewer type check:', {
|
||||
exists: typeof window.logViewer !== 'undefined',
|
||||
isObject: typeof window.logViewer === 'object',
|
||||
constructor: window.logViewer ? window.logViewer.constructor.name : 'unknown',
|
||||
hasInfoMethod: window.logViewer && typeof window.logViewer.info === 'function',
|
||||
methods: window.logViewer ? Object.getOwnPropertyNames(window.logViewer).filter(name =>
|
||||
typeof window.logViewer[name] === 'function') : []
|
||||
});
|
||||
|
||||
// Initialize session
|
||||
initializeSession();
|
||||
|
||||
// Add event listeners
|
||||
if (sendButton) {
|
||||
sendButton.onclick = function() {
|
||||
console.log('Send button clicked');
|
||||
sendMessage();
|
||||
};
|
||||
}
|
||||
|
||||
if (queryInput) {
|
||||
queryInput.onkeydown = function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
console.log('Enter key pressed');
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Start a session
|
||||
function initializeSession() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '{{ url_for("specialist.start_session") }}', true);
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
var response = JSON.parse(xhr.responseText);
|
||||
console.log('Session initialized:', response.session_id);
|
||||
sessionIdField.value = response.session_id;
|
||||
} else {
|
||||
console.error('Failed to initialize session');
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() {
|
||||
console.error('Error initializing session');
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
// Send message function
|
||||
function sendMessage() {
|
||||
console.log('Sending message');
|
||||
|
||||
// Prevent double execution
|
||||
if (window.isSendingMessage) {
|
||||
console.log('Already sending a message, ignoring');
|
||||
return;
|
||||
}
|
||||
|
||||
window.isSendingMessage = true;
|
||||
|
||||
// Get message text
|
||||
var messageText = queryInput.value.trim();
|
||||
if (!messageText) {
|
||||
console.log('No message to send');
|
||||
window.isSendingMessage = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get form values
|
||||
var configValues = getConfigValues();
|
||||
|
||||
// Remember original message
|
||||
var originalMessage = messageText;
|
||||
|
||||
// Clear input
|
||||
queryInput.value = '';
|
||||
|
||||
// Prepare form data
|
||||
var formData = new FormData();
|
||||
formData.append('specialist_id', specialistId);
|
||||
formData.append('session_id', sessionIdField.value);
|
||||
formData.append('query', messageText);
|
||||
|
||||
// Add config values
|
||||
for (var key in configValues) {
|
||||
formData.append(key, configValues[key]);
|
||||
}
|
||||
|
||||
// Send request
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '{{ url_for("specialist.execute_specialist") }}', true);
|
||||
xhr.setRequestHeader('HX-Request', 'true');
|
||||
|
||||
xhr.onload = function() {
|
||||
window.isSendingMessage = false;
|
||||
|
||||
if (xhr.status === 200) {
|
||||
console.log('Response received');
|
||||
|
||||
// Add AI response HTML to the message history
|
||||
messageHistory.innerHTML += xhr.responseText;
|
||||
console.log('Added response HTML to message history');
|
||||
|
||||
// Extract the task_id from the response
|
||||
const taskIdMatch = xhr.responseText.match(/id="response-([^"]+)"/);
|
||||
if (taskIdMatch && taskIdMatch[1]) {
|
||||
const taskId = taskIdMatch[1];
|
||||
console.log(`Extracted task ID: ${taskId}`);
|
||||
|
||||
// Set up the SSE connection for this task
|
||||
setupSSEConnection(taskId);
|
||||
} else {
|
||||
console.error('Could not extract task_id from response');
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
messageHistory.scrollTop = messageHistory.scrollHeight;
|
||||
} else {
|
||||
console.error('Request failed:', xhr.status);
|
||||
addErrorMessage('Failed to process your request');
|
||||
queryInput.value = originalMessage;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
window.isSendingMessage = false;
|
||||
console.error('Request error');
|
||||
addErrorMessage('Request failed. Please try again.');
|
||||
queryInput.value = originalMessage;
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
// Helper to add user message
|
||||
function addUserMessage(text) {
|
||||
var userMessageHtml =
|
||||
'<div class="message-container user-container">' +
|
||||
'<div class="message-bubble user-message">' + text + '</div>' +
|
||||
'</div>';
|
||||
messageHistory.innerHTML += userMessageHtml;
|
||||
messageHistory.scrollTop = messageHistory.scrollHeight;
|
||||
}
|
||||
|
||||
// Helper to add error message
|
||||
function addErrorMessage(text) {
|
||||
var errorMessageHtml =
|
||||
'<div class="message-container ai-container">' +
|
||||
'<div class="message-bubble ai-message" style="background-color: var(--bs-danger);">' +
|
||||
'Error: ' + text + '</div>' +
|
||||
'</div>';
|
||||
messageHistory.innerHTML += errorMessageHtml;
|
||||
messageHistory.scrollTop = messageHistory.scrollHeight;
|
||||
}
|
||||
|
||||
// Get values from config form
|
||||
function getConfigValues() {
|
||||
var configForm = document.getElementById('specialistConfigForm');
|
||||
var values = {};
|
||||
|
||||
if (configForm) {
|
||||
var elements = configForm.elements;
|
||||
for (var i = 0; i < elements.length; i++) {
|
||||
var element = elements[i];
|
||||
if (element.name && element.name !== 'specialist_id') {
|
||||
if (element.type === 'checkbox') {
|
||||
values[element.name] = element.checked ? 'true' : 'false';
|
||||
} else {
|
||||
values[element.name] = element.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the UI when DOM is fully loaded
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
// Document already ready, call immediately
|
||||
console.log('Document already ready');
|
||||
initializeUI();
|
||||
} else {
|
||||
// Set up event listener
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOMContentLoaded event fired');
|
||||
initializeUI();
|
||||
});
|
||||
}
|
||||
|
||||
// Backup initialization in case DOMContentLoaded never fires
|
||||
setTimeout(function() {
|
||||
console.log('Backup initialization');
|
||||
if (!window.uiInitialized) {
|
||||
window.uiInitialized = true;
|
||||
initializeUI();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
console.log('Script execution completed');
|
||||
})();
|
||||
</script>
|
||||
57
eveai_client/platform/templates/specialist_list.html
Normal file
57
eveai_client/platform/templates/specialist_list.html
Normal file
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}EveAI - Specialists{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex flex-row h-100" style="height: calc(100vh - 120px) !important;">
|
||||
<!-- Fixed-width specialist selector -->
|
||||
<div class="specialist-selector" style="width: 320px; overflow-y: auto; flex-shrink: 0;">
|
||||
<div class="p-3">
|
||||
{% for specialist in specialists %}
|
||||
<div class="card mb-3 specialist-card"
|
||||
hx-get="{{ url_for('specialist.get_specialist_form', specialist_id=specialist.id) }}"
|
||||
hx-target="#specialist-form-container">
|
||||
<div class="card-body p-3">
|
||||
<h3 class="card-title mb-2">{{ specialist.name }}</h3>
|
||||
<p class="card-text">{{ specialist.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flexible specialist form area -->
|
||||
<div class="flex-grow-1 d-flex flex-column overflow-hidden">
|
||||
<div id="specialist-form-container" class="flex-grow-1 d-flex flex-column overflow-hidden">
|
||||
<div class="card border-0 h-100 d-flex flex-column">
|
||||
<div class="card-body d-flex align-items-center justify-content-center">
|
||||
<p class="text-muted mb-0">Select a specialist to start</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add click styling to specialist cards
|
||||
const specialistCards = document.querySelectorAll('.specialist-card');
|
||||
|
||||
specialistCards.forEach(card => {
|
||||
card.addEventListener('click', function() {
|
||||
// Remove active class from all cards
|
||||
specialistCards.forEach(c => {
|
||||
c.classList.remove('bg-light');
|
||||
c.classList.remove('active-specialist');
|
||||
});
|
||||
|
||||
// Add active class to clicked card
|
||||
this.classList.add('bg-light');
|
||||
this.classList.add('active-specialist');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user