refactor: remove legacy Thymeleaf web UI controllers and templates (#5406)

# Description of Changes

This pull request completes the removal of Thymeleaf template engine
support and documentation from the Stirling-PDF codebase and developer
guides, reflecting the project's full migration to a React-based
frontend. It removes all references to Thymeleaf in code, configuration,
and documentation, and updates guides to focus exclusively on the React
SPA architecture. This streamlines the codebase and clarifies the
development workflow for contributors.

**Codebase cleanup: Thymeleaf removal**
- Removed all commented-out Thymeleaf dependencies and related
configuration beans from `build.gradle` and `AppConfig.java`
(`app/common/build.gradle`,
`app/common/src/main/java/stirling/software/common/configuration/AppConfig.java`)
[[1]](diffhunk://#diff-2a1a21726f33b05d16451237c68d6df91a5f4a58419d839715f3f1538a9a14aeL32)
[[2]](diffhunk://#diff-70792df9a0ab5675ded888c9eb8e2815c780d7b39f4bda8cf2da51d1b336899aL67-L76).
- Fully commented out (as a precursor to future deletion) the
`FileFallbackTemplateResolver` and `InputStreamTemplateResource`
classes, which were only used for Thymeleaf template resolution
(`app/common/src/main/java/stirling/software/common/configuration/FileFallbackTemplateResolver.java`,
`app/common/src/main/java/stirling/software/common/model/InputStreamTemplateResource.java`)
[[1]](diffhunk://#diff-e2bc7614074316b972355cb7dda47b98f75b00eb6b2ca4f143a680ab2803dcd8L1-L49)
[[2]](diffhunk://#diff-ab10ee12d8de8fb77759e931170373d388bde04bad6d0e42a0ab674355ef7ef3L1-L40).

**Documentation updates: React-only focus**
- Removed all instructions and references to migrating or developing
with Thymeleaf templates in `DeveloperGuide.md` and `ADDING_TOOLS.md`,
including detailed Thymeleaf usage examples, migration steps, and
translation key usage in templates
[[1]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL5-R5)
[[2]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL41-L43)
[[3]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL103-L105)
[[4]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL157)
[[5]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL312)
[[6]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL404-R396)
[[7]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL451-L505)
[[8]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL530-R467)
[[9]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL585-L669)
[[10]](diffhunk://#diff-ccd22fcbec8148152c8c77b85fbfe2633a6707b5ad50c2ef88fa87e2c47ea88fL699-L709)
[[11]](diffhunk://#diff-e2f8148ea620602b7761e8ee24afeac1c577476630528e210fe0b22e950016ddL3-R3)
[[12]](diffhunk://#diff-e2f8148ea620602b7761e8ee24afeac1c577476630528e210fe0b22e950016ddL267-R267).
- Updated architecture descriptions in `CLAUDE.md` to reflect that the
frontend is now exclusively a React SPA and that Thymeleaf templates
have been fully replaced
[[1]](diffhunk://#diff-6ebdb617a8104a7756d0cf36578ab01103dc9f07e4dc6feb751296b9c402faf7L131-R132)
[[2]](diffhunk://#diff-6ebdb617a8104a7756d0cf36578ab01103dc9f07e4dc6feb751296b9c402faf7L143-L144).

**Labeler configuration update**
- Removed labeler rules for files related to the old Thymeleaf-based web
controllers and UI directories, as these are now obsolete
(`.github/labeler-config-srvaroa.yml`).

These changes ensure the codebase and documentation are consistent with
the new React-only frontend approach, reducing maintenance overhead and
potential confusion for contributors.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Ludy
2026-01-21 21:58:29 +00:00
committed by GitHub
parent b58efaf388
commit 80cba55459
135 changed files with 11 additions and 18244 deletions
@@ -1,239 +0,0 @@
.dashboard-card {
margin-bottom: 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
border: 1px solid var(--md-sys-color-outline-variant);
}
.card-header {
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.card-body {
background-color: var(--md-sys-color-surface-container);
}
.stat-card {
text-align: center;
padding: 20px;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
}
.stat-label {
font-size: 1rem;
color: var(--md-sys-color-on-surface-variant);
}
.chart-container {
position: relative;
height: 300px;
width: 100%;
}
.filter-card {
margin-bottom: 20px;
padding: 15px;
background-color: var(--md-sys-color-surface-container-low);
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: 4px;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--md-sys-color-surface-container-high, rgba(229, 232, 241, 0.8));
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.level-indicator {
display: inline-block;
padding: 5px 10px;
border-radius: 15px;
color: white;
font-weight: bold;
}
.level-0 {
background-color: var(--md-sys-color-error, #dc3545); /* Red */
}
.level-1 {
background-color: var(--md-sys-color-secondary, #fd7e14); /* Orange */
}
.level-2 {
background-color: var(--md-nav-section-color-other, #28a745); /* Green */
}
.level-3 {
background-color: var(--md-sys-color-tertiary, #17a2b8); /* Teal */
}
/* Custom data table styling */
.audit-table {
font-size: 0.9rem;
color: var(--md-sys-color-on-surface);
border-color: var(--md-sys-color-outline-variant);
}
.audit-table tbody tr {
background-color: var(--md-sys-color-surface-container-low);
}
.audit-table tbody tr:nth-child(even) {
background-color: var(--md-sys-color-surface-container);
}
.audit-table tbody tr:hover {
background-color: var(--md-sys-color-surface-container-high);
}
.audit-table th {
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface);
position: sticky;
top: 0;
z-index: 10;
font-weight: bold;
}
.table-responsive {
max-height: 600px;
}
.pagination-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 15px;
padding: 10px 0;
border-top: 1px solid var(--md-sys-color-outline-variant);
color: var(--md-sys-color-on-surface);
}
.pagination .page-item.active .page-link {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: white;
}
.pagination .page-link {
color: var(--bs-primary);
}
.pagination .page-link.disabled {
pointer-events: none;
color: var(--bs-secondary);
background-color: var(--bs-light);
}
.json-viewer {
background-color: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface);
border-radius: 4px;
padding: 15px;
max-height: 350px;
overflow-y: auto;
font-family: monospace;
font-size: 0.9rem;
white-space: pre-wrap;
border: 1px solid var(--md-sys-color-outline-variant);
margin-top: 10px;
}
/* Simple, minimal radio styling - no extras */
.form-check {
padding: 8px 0;
}
#debug-console {
position: fixed;
bottom: 0;
right: 0;
width: 400px;
height: 200px;
background: var(--md-sys-color-surface-container-highest, rgba(0,0,0,0.8));
color: var(--md-sys-color-tertiary, #0f0);
font-family: monospace;
font-size: 12px;
z-index: 9999;
overflow-y: auto;
padding: 10px;
border: 1px solid var(--md-sys-color-outline);
display: none; /* Changed to none by default, enable with key command */
}
/* Enhanced styling for radio buttons as buttons */
label.btn-outline-primary {
cursor: pointer;
transition: all 0.2s;
border-color: var(--md-sys-color-primary);
color: var(--md-sys-color-primary);
}
label.btn-outline-primary.active {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-color: var(--md-sys-color-primary);
}
label.btn-outline-primary input[type="radio"] {
cursor: pointer;
}
/* Modal overrides for dark mode */
.modal-content {
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
border-color: var(--md-sys-color-outline);
}
.modal-header {
border-bottom-color: var(--md-sys-color-outline-variant);
}
.modal-footer {
border-top-color: var(--md-sys-color-outline-variant);
}
/* Improved modal positioning */
.modal-dialog-centered {
display: flex;
align-items: center;
min-height: calc(100% - 3.5rem);
}
.modal {
z-index: 1050;
}
/* Button overrides for theme consistency */
.btn-outline-primary {
color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
.btn-outline-primary:hover {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
.btn-outline-secondary {
color: var(--md-sys-color-secondary);
border-color: var(--md-sys-color-secondary);
}
.btn-outline-secondary:hover {
background-color: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
}
.btn-primary {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-color: var(--md-sys-color-primary);
}
.btn-secondary {
background-color: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
border-color: var(--md-sys-color-secondary);
}
@@ -1,394 +0,0 @@
/* modern-tables.css - Professional styling for data tables and related elements */
/* Main container - Reduced max-width from 1100px to 900px */
.data-container {
max-width: 900px;
margin: 2rem auto;
background-color: var(--md-sys-color-surface-container-lowest);
border-radius: 1rem;
padding: 0.5rem;
box-shadow: 0 2px 12px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.05);
}
/* Panel / Card */
.data-panel {
background-color: var(--md-sys-color-surface);
border-radius: 0.75rem;
box-shadow: 0 2px 8px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.08);
overflow: hidden;
}
/* Header */
.data-header {
display: flex;
align-items: center;
padding: 1.25rem 1.5rem;
background-color: var(--md-sys-color-surface-variant);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.data-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.75rem;
}
.data-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-radius: 0.5rem;
transition: all 0.2s ease;
}
/* Content area */
.data-body {
padding: 1.5rem;
background-color: var(--md-sys-color-surface-container-low);
border-radius: 0.5rem;
}
/* Action buttons container */
.data-actions {
display: flex;
justify-content: center;
margin: 1rem 0 1.5rem;
gap: 0.75rem;
}
/* Can add these classes for different alignments */
.data-actions-start {
justify-content: flex-start;
}
.data-actions-end {
justify-content: flex-end;
}
/* Button styling */
.data-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s ease;
border: none;
cursor: pointer;
text-decoration: none;
}
/* Fixed button colors - normal state has more contrast now */
.data-btn-primary {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
.data-btn-primary:hover {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-primary);
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
}
.data-btn-secondary {
background-color: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
}
.data-btn-secondary:hover {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-secondary);
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
}
.data-btn-danger {
background-color: var(--md-sys-color-error);
color: var(--md-sys-color-on-error);
}
.data-btn-danger:hover {
background-color: var(--md-sys-color-error-container);
color: var(--md-sys-color-error);
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
}
.data-btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
/* Icon button */
.data-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
background-color: transparent;
}
/* Fixed icon button colors */
.data-icon-btn-primary {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
.data-icon-btn-primary:hover {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-primary);
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
}
.data-icon-btn-danger {
background-color: var(--md-sys-color-error);
color: var(--md-sys-color-on-error);
}
.data-icon-btn-danger:hover {
background-color: var(--md-sys-color-error-container);
color: var(--md-sys-color-error);
box-shadow: 0 2px 4px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.1);
}
/* Table styling */
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.data-table th {
text-align: left;
padding: 1rem;
background-color: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
font-weight: 600;
position: sticky;
top: 0;
}
.data-table th:first-child {
border-top-left-radius: 0.5rem;
}
.data-table th:last-child {
border-top-right-radius: 0.5rem;
}
.data-table td {
padding: 1rem;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.data-table tr:last-child td {
border-bottom: none;
}
.data-table tr:hover {
background-color: rgba(var(--md-sys-color-surface-variant-rgb), 0.5);
}
/* Table action cells */
.data-action-cell {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-start;
}
.data-action-cell-center {
justify-content: center;
}
.data-action-cell-end {
justify-content: flex-end;
}
/* Status indicators */
.data-status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
font-weight: 500;
}
.data-status-success {
background-color: var(--md-sys-color-tertiary-container);
color: var(--md-sys-color-tertiary);
}
.data-status-danger {
background-color: var(--md-sys-color-error-container);
color: var(--md-sys-color-error);
}
.data-status-warning {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-secondary);
}
.data-status-info {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-primary);
}
/* Stats/Info container */
.data-stats {
display: flex;
gap: 1.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.data-stat-card {
background-color: var(--md-sys-color-surface-variant);
border-radius: 0.5rem;
padding: 1.25rem;
flex: 1;
min-width: 180px;
box-shadow: 0 2px 8px rgba(var(--md-sys-color-shadow, 0, 0, 0), 0.05);
}
.data-stat-label {
font-size: 0.875rem;
color: var(--md-sys-color-on-surface-variant);
margin-bottom: 0.5rem;
}
.data-stat-value {
font-size: 1.75rem;
font-weight: 700;
color: var(--md-sys-color-on-surface);
}
/* Section title */
.data-section-title {
font-size: 1.25rem;
font-weight: 600;
margin: 1.5rem 0 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
/* Empty state styling */
.data-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--md-sys-color-on-surface-variant);
}
.data-empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.7;
}
.data-empty-text {
font-size: 1.125rem;
margin-bottom: 1.5rem;
}
/* Modal styling */
.data-modal {
border-radius: 0.75rem;
overflow: hidden;
}
.data-modal-header {
background-color: var(--md-sys-color-surface-variant);
padding: 1.25rem;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
display: flex;
align-items: center;
justify-content: space-between;
}
.data-modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Modal close button styling */
.data-btn-close {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background-color: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
border: none;
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
margin: 0;
}
.data-btn-close:hover {
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface);
}
.data-btn-close .material-symbols-rounded {
font-size: 1.25rem;
}
.data-modal-body {
padding: 1.5rem;
}
.data-modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--md-sys-color-outline-variant);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Form elements */
.data-form-group {
margin-bottom: 1.25rem;
}
.data-form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.data-form-control {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 1px solid
}
.text-overflow {
max-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -1,999 +0,0 @@
// Initialize variables
let currentPage = 0;
let pageSize = 20;
let totalPages = 0;
let typeFilter = '';
let principalFilter = '';
let startDateFilter = '';
let endDateFilter = '';
// Charts
let typeChart;
let userChart;
let timeChart;
// DOM elements - will properly initialize these during page load
let auditTableBody;
let pageSizeSelect;
let typeFilterInput;
let exportTypeFilterInput;
let principalFilterInput;
let startDateFilterInput;
let endDateFilterInput;
let applyFiltersButton;
let resetFiltersButton;
// Initialize page
// Theme change listener to redraw charts when theme changes
function setupThemeChangeListener() {
// Watch for theme changes (usually by a class on body or html element)
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.attributeName === 'data-bs-theme' || mutation.attributeName === 'class') {
// Redraw charts with new theme colors if they exist
if (typeChart && userChart && timeChart) {
// If we have stats data cached, use it
if (window.cachedStatsData) {
renderCharts(window.cachedStatsData);
}
}
}
});
});
// Observe the document element for theme changes
observer.observe(document.documentElement, { attributes: true });
// Also observe body for class changes
observer.observe(document.body, { attributes: true });
}
document.addEventListener('DOMContentLoaded', function() {
// Initialize DOM references
auditTableBody = document.getElementById('auditTableBody');
pageSizeSelect = document.getElementById('pageSizeSelect');
typeFilterInput = document.getElementById('typeFilter');
exportTypeFilterInput = document.getElementById('exportTypeFilter');
principalFilterInput = document.getElementById('principalFilter');
startDateFilterInput = document.getElementById('startDateFilter');
endDateFilterInput = document.getElementById('endDateFilter');
applyFiltersButton = document.getElementById('applyFilters');
resetFiltersButton = document.getElementById('resetFilters');
// Load event types for dropdowns
loadEventTypes();
// Show a loading message immediately
if (auditTableBody) {
auditTableBody.innerHTML =
'<tr><td colspan="5" class="text-center"><div class="spinner-border spinner-border-sm" role="status"></div> ' + window.i18n.loading + '</td></tr>';
}
// Make a direct API call first to avoid validation issues
loadAuditData(0, pageSize);
// Load statistics for dashboard
loadStats(7);
// Setup theme change listener
setupThemeChangeListener();
// Set up event listeners
pageSizeSelect.addEventListener('change', function() {
pageSize = parseInt(this.value);
window.originalPageSize = pageSize;
currentPage = 0;
window.requestedPage = 0;
loadAuditData(0, pageSize);
});
applyFiltersButton.addEventListener('click', function() {
typeFilter = typeFilterInput.value.trim();
principalFilter = principalFilterInput.value.trim();
startDateFilter = startDateFilterInput.value;
endDateFilter = endDateFilterInput.value;
currentPage = 0;
window.requestedPage = 0;
loadAuditData(0, pageSize);
});
resetFiltersButton.addEventListener('click', function() {
// Reset input fields
typeFilterInput.value = '';
principalFilterInput.value = '';
startDateFilterInput.value = '';
endDateFilterInput.value = '';
// Reset filter variables
typeFilter = '';
principalFilter = '';
startDateFilter = '';
endDateFilter = '';
// Reset page
currentPage = 0;
window.requestedPage = 0;
// Update UI
document.getElementById('currentPage').textContent = '1';
// Load data with reset filters
loadAuditData(0, pageSize);
});
// Reset export filters button
document.getElementById('resetExportFilters').addEventListener('click', function() {
exportTypeFilter.value = '';
exportPrincipalFilter.value = '';
exportStartDateFilter.value = '';
exportEndDateFilter.value = '';
});
// Make radio buttons behave like toggle buttons
const radioLabels = document.querySelectorAll('label.btn-outline-primary');
radioLabels.forEach(label => {
const radio = label.querySelector('input[type="radio"]');
if (radio) {
// Highlight the checked radio button's label
if (radio.checked) {
label.classList.add('active');
}
// Handle clicking on the label
label.addEventListener('click', function() {
// Remove active class from all labels
radioLabels.forEach(l => l.classList.remove('active'));
// Add active class to this label
this.classList.add('active');
// Check this radio button
radio.checked = true;
});
}
});
// Handle export button
exportButton.onclick = function(e) {
e.preventDefault();
// Get selected format with fallback
const selectedRadio = document.querySelector('input[name="exportFormat"]:checked');
const exportFormat = selectedRadio ? selectedRadio.value : 'csv';
exportAuditData(exportFormat);
return false;
};
// Set up pagination buttons
document.getElementById('page-first').onclick = function() {
if (currentPage > 0) {
goToPage(0);
}
return false;
};
document.getElementById('page-prev').onclick = function() {
if (currentPage > 0) {
goToPage(currentPage - 1);
}
return false;
};
document.getElementById('page-next').onclick = function() {
if (currentPage < totalPages - 1) {
goToPage(currentPage + 1);
}
return false;
};
document.getElementById('page-last').onclick = function() {
if (totalPages > 0 && currentPage < totalPages - 1) {
goToPage(totalPages - 1);
}
return false;
};
// Set up tab change events
const tabEls = document.querySelectorAll('button[data-bs-toggle="tab"]');
tabEls.forEach(tabEl => {
tabEl.addEventListener('shown.bs.tab', function (event) {
const targetId = event.target.getAttribute('data-bs-target');
if (targetId === '#dashboard') {
// Redraw charts when dashboard tab is shown
if (typeChart) typeChart.update();
if (userChart) userChart.update();
if (timeChart) timeChart.update();
}
});
});
});
// Load audit data from server
function loadAuditData(targetPage, realPageSize) {
const requestedPage = targetPage !== undefined ? targetPage : window.requestedPage || 0;
realPageSize = realPageSize || pageSize;
showLoading('table-loading');
// Always request page 0 from server, but with increased page size if needed
let url = `/api/v1/audit/data?page=${requestedPage}&size=${realPageSize}`;
if (typeFilter) url += `&type=${encodeURIComponent(typeFilter)}`;
if (principalFilter) url += `&principal=${encodeURIComponent(principalFilter)}`;
if (startDateFilter) url += `&startDate=${startDateFilter}`;
if (endDateFilter) url += `&endDate=${endDateFilter}`;
// Update page indicator
if (document.getElementById('page-indicator')) {
document.getElementById('page-indicator').textContent = `Page ${requestedPage + 1} of ?`;
}
fetchWithCsrf(url)
.then(response => {
return response.json();
})
.then(data => {
// Calculate the correct slice of data to show for the requested page
let displayContent = data.content;
// Render the correct slice of data
renderTable(displayContent);
// Calculate total pages based on the actual total elements
const calculatedTotalPages = Math.ceil(data.totalElements / realPageSize);
totalPages = calculatedTotalPages;
currentPage = requestedPage; // Use our tracked page, not server's
// Update UI
document.getElementById('currentPage').textContent = currentPage + 1;
document.getElementById('totalPages').textContent = totalPages;
document.getElementById('totalRecords').textContent = data.totalElements;
if (document.getElementById('page-indicator')) {
document.getElementById('page-indicator').textContent = `Page ${currentPage + 1} of ${totalPages}`;
}
// Re-enable buttons with correct state
document.getElementById('page-first').disabled = currentPage === 0;
document.getElementById('page-prev').disabled = currentPage === 0;
document.getElementById('page-next').disabled = currentPage >= totalPages - 1;
document.getElementById('page-last').disabled = currentPage >= totalPages - 1;
hideLoading('table-loading');
// Restore original page size for next operations
if (window.originalPageSize && realPageSize !== window.originalPageSize) {
pageSize = window.originalPageSize;
}
// Store original page size for recovery
window.originalPageSize = realPageSize;
// Clear busy flag
window.paginationBusy = false;
})
.catch(error => {
if (auditTableBody) {
auditTableBody.innerHTML = `<tr><td colspan="5" class="text-center">${window.i18n.errorLoading} ${error.message}</td></tr>`;
}
hideLoading('table-loading');
// Re-enable buttons
document.getElementById('page-first').disabled = false;
document.getElementById('page-prev').disabled = false;
document.getElementById('page-next').disabled = false;
document.getElementById('page-last').disabled = false;
// Clear busy flag
window.paginationBusy = false;
});
}
// Load statistics for charts
function loadStats(days) {
showLoading('type-chart-loading');
showLoading('user-chart-loading');
showLoading('time-chart-loading');
fetchWithCsrf(`/api/v1/audit/stats?days=${days}`)
.then(response => response.json())
.then(data => {
document.getElementById('total-events').textContent = data.totalEvents;
// Cache stats data for theme changes
window.cachedStatsData = data;
renderCharts(data);
hideLoading('type-chart-loading');
hideLoading('user-chart-loading');
hideLoading('time-chart-loading');
})
.catch(error => {
console.error('Error loading stats:', error);
hideLoading('type-chart-loading');
hideLoading('user-chart-loading');
hideLoading('time-chart-loading');
});
}
// Export audit data
function exportAuditData(format) {
const type = exportTypeFilter.value.trim();
const principal = exportPrincipalFilter.value.trim();
const startDate = exportStartDateFilter.value;
const endDate = exportEndDateFilter.value;
let url = format === 'json' ? '/api/v1/audit/export/json?' : '/api/v1/audit/export/csv?';
if (type) url += `&type=${encodeURIComponent(type)}`;
if (principal) url += `&principal=${encodeURIComponent(principal)}`;
if (startDate) url += `&startDate=${startDate}`;
if (endDate) url += `&endDate=${endDate}`;
// Trigger download
window.location.href = url;
}
// Render table with audit data
function renderTable(events) {
if (!events || events.length === 0) {
auditTableBody.innerHTML = '<tr><td colspan="5" class="text-center">' + window.i18n.noEventsFound + '</td></tr>';
return;
}
try {
auditTableBody.innerHTML = '';
events.forEach((event, index) => {
try {
const row = document.createElement('tr');
row.innerHTML = `
<td>${event.id || 'N/A'}</td>
<td>${formatDate(event.timestamp)}</td>
<td>${escapeHtml(event.principal || 'N/A')}</td>
<td>${escapeHtml(event.type || 'N/A')}</td>
<td><button class="btn btn-sm btn-outline-primary view-details">${window.i18n.viewDetails || 'View Details'}</button></td>
`;
// Store event data for modal
row.dataset.event = JSON.stringify(event);
// Add click handler for details button
const detailsButton = row.querySelector('.view-details');
if (detailsButton) {
detailsButton.addEventListener('click', function() {
showEventDetails(event);
});
}
auditTableBody.appendChild(row);
} catch (rowError) {
}
});
} catch (e) {
auditTableBody.innerHTML = '<tr><td colspan="5" class="text-center">' + window.i18n.errorRendering + ' ' + e.message + '</td></tr>';
}
}
// Show event details in modal
function showEventDetails(event) {
// Get modal elements by ID with correct hyphenated IDs from HTML
const modalId = document.getElementById('modal-id');
const modalPrincipal = document.getElementById('modal-principal');
const modalType = document.getElementById('modal-type');
const modalTimestamp = document.getElementById('modal-timestamp');
const modalData = document.getElementById('modal-data');
const eventDetailsModal = document.getElementById('eventDetailsModal');
// Set modal content
if (modalId) modalId.textContent = event.id;
if (modalPrincipal) modalPrincipal.textContent = event.principal;
if (modalType) modalType.textContent = event.type;
if (modalTimestamp) modalTimestamp.textContent = formatDate(event.timestamp);
// Format JSON data
if (modalData) {
try {
const dataObj = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
modalData.textContent = JSON.stringify(dataObj, null, 2);
} catch (e) {
modalData.textContent = event.data || 'No data available';
}
}
// Show the modal
if (eventDetailsModal) {
const modal = new bootstrap.Modal(eventDetailsModal);
modal.show();
}
}
// No need for a dynamic pagination renderer anymore as we're using static buttons
// Direct pagination approach - server seems to be hard-limited to returning 20 items
function goToPage(page) {
// Basic validation - totalPages may not be initialized on first load
if (page < 0) {
return;
}
// Skip validation against totalPages on first load
if (totalPages > 0 && page >= totalPages) {
return;
}
// Simple guard flag
if (window.paginationBusy) {
return;
}
window.paginationBusy = true;
try {
// Store the requested page for later
window.requestedPage = page;
currentPage = page;
// Update UI immediately for user feedback
document.getElementById('currentPage').textContent = page + 1;
// Load data with this page
loadAuditData(page, pageSize);
} catch (e) {
window.paginationBusy = false;
}
}
// Render charts
function renderCharts(data) {
// Get theme colors
const colors = getThemeColors();
// Prepare data for charts
const typeLabels = Object.keys(data.eventsByType);
const typeValues = Object.values(data.eventsByType);
const userLabels = Object.keys(data.eventsByPrincipal);
const userValues = Object.values(data.eventsByPrincipal);
// Sort days for time chart
const timeLabels = Object.keys(data.eventsByDay).sort();
const timeValues = timeLabels.map(day => data.eventsByDay[day] || 0);
// Chart.js global defaults for dark mode compatibility
Chart.defaults.color = colors.text;
Chart.defaults.borderColor = colors.grid;
// Type chart
if (typeChart) {
typeChart.destroy();
}
const typeCtx = document.getElementById('typeChart').getContext('2d');
typeChart = new Chart(typeCtx, {
type: 'bar',
data: {
labels: typeLabels,
datasets: [{
label: window.i18n.eventsByType,
data: typeValues,
backgroundColor: colors.chartColors.slice(0, typeLabels.length).map(color => {
// Add transparency to the colors
if (color.startsWith('rgb(')) {
return color.replace('rgb(', 'rgba(').replace(')', ', 0.8)');
}
return color;
}),
borderColor: colors.chartColors.slice(0, typeLabels.length),
borderWidth: 2,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
},
usePointStyle: true,
pointStyle: 'rectRounded',
boxWidth: 12,
boxHeight: 12,
}
},
tooltip: {
titleFont: {
weight: 'bold',
size: 14
},
bodyFont: {
size: 13
},
backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)',
titleColor: colors.isDarkMode ? '#ffffff' : '#000000',
bodyColor: colors.isDarkMode ? '#ffffff' : '#000000',
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid,
borderWidth: 1,
padding: 10,
cornerRadius: 6,
callbacks: {
label: function(context) {
return `${context.dataset.label}: ${context.raw}`;
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 12
},
precision: 0 // Only show whole numbers
},
grid: {
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid
},
title: {
display: true,
text: 'Count',
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
}
}
},
x: {
ticks: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 11
},
callback: function(value, index) {
// Get the original label
const label = this.getLabelForValue(value);
// If the label is too long, truncate it
const maxLength = 10;
if (label.length > maxLength) {
return label.substring(0, maxLength) + '...';
}
return label;
},
autoSkip: true,
maxRotation: 0,
minRotation: 0
},
grid: {
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid,
display: false // Hide vertical gridlines for cleaner look
},
title: {
display: true,
text: 'Event Type',
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
},
padding: {top: 10, bottom: 0}
}
}
}
}
});
// User chart
if (userChart) {
userChart.destroy();
}
const userCtx = document.getElementById('userChart').getContext('2d');
userChart = new Chart(userCtx, {
type: 'pie',
data: {
labels: userLabels,
datasets: [{
label: window.i18n.eventsByUser,
data: userValues,
backgroundColor: colors.chartColors.slice(0, userLabels.length),
borderWidth: 2,
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.5)'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
color: colors.text,
font: {
size: colors.isDarkMode ? 14 : 12,
weight: colors.isDarkMode ? 'bold' : 'normal'
},
padding: 15,
usePointStyle: true,
pointStyle: 'circle',
boxWidth: 10,
boxHeight: 10,
// Add a box around each label for better contrast in dark mode
generateLabels: function(chart) {
const original = Chart.overrides.pie.plugins.legend.labels.generateLabels;
const labels = original.call(this, chart);
if (colors.isDarkMode) {
labels.forEach(label => {
// Enhance contrast for dark mode
label.fillStyle = label.fillStyle; // Keep original fill
label.strokeStyle = 'rgba(255, 255, 255, 0.8)'; // White border
label.lineWidth = 2; // Thicker border
});
}
return labels;
}
}
},
tooltip: {
titleFont: {
weight: 'bold',
size: 14
},
bodyFont: {
size: 13
},
backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)',
titleColor: colors.isDarkMode ? '#ffffff' : '#000000',
bodyColor: colors.isDarkMode ? '#ffffff' : '#000000',
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid,
borderWidth: 1,
padding: 10,
cornerRadius: 6
}
}
}
});
// Time chart
if (timeChart) {
timeChart.destroy();
}
const timeCtx = document.getElementById('timeChart').getContext('2d');
// Get first color for line chart with appropriate transparency
let bgColor, borderColor;
if (colors.isDarkMode) {
bgColor = 'rgba(162, 201, 255, 0.3)'; // Light blue with transparency
borderColor = 'rgb(162, 201, 255)'; // Light blue solid
} else {
bgColor = 'rgba(0, 96, 170, 0.2)'; // Dark blue with transparency
borderColor = 'rgb(0, 96, 170)'; // Dark blue solid
}
timeChart = new Chart(timeCtx, {
type: 'line',
data: {
labels: timeLabels,
datasets: [{
label: window.i18n.eventsOverTime,
data: timeValues,
backgroundColor: bgColor,
borderColor: borderColor,
borderWidth: 3,
tension: 0.2,
fill: true,
pointBackgroundColor: borderColor,
pointBorderColor: colors.isDarkMode ? '#fff' : '#000',
pointBorderWidth: 2,
pointRadius: 5,
pointHoverRadius: 7
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
},
usePointStyle: true,
pointStyle: 'line',
boxWidth: 50,
boxHeight: 3
}
},
tooltip: {
titleFont: {
weight: 'bold',
size: 14
},
bodyFont: {
size: 13
},
backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)',
titleColor: colors.isDarkMode ? '#ffffff' : '#000000',
bodyColor: colors.isDarkMode ? '#ffffff' : '#000000',
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid,
borderWidth: 1,
padding: 10,
cornerRadius: 6,
callbacks: {
label: function(context) {
return `Events: ${context.raw}`;
}
}
}
},
interaction: {
intersect: false,
mode: 'index'
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 12
},
precision: 0 // Only show whole numbers
},
grid: {
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid
},
title: {
display: true,
text: 'Number of Events',
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
}
}
},
x: {
ticks: {
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 12
},
maxRotation: 45,
minRotation: 45
},
grid: {
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid
},
title: {
display: true,
text: 'Date',
color: colors.text,
font: {
weight: colors.isDarkMode ? 'bold' : 'normal',
size: 14
},
padding: {top: 20}
}
}
}
}
});
}
// Helper functions
function formatDate(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString();
}
function escapeHtml(text) {
if (!text) return '';
return text
.toString()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function showLoading(id) {
const loading = document.getElementById(id);
if (loading) loading.style.display = 'flex';
}
function hideLoading(id) {
const loading = document.getElementById(id);
if (loading) loading.style.display = 'none';
}
// Load event types from the server for filter dropdowns
function loadEventTypes() {
fetchWithCsrf('/api/v1/audit/types')
.then(response => response.json())
.then(types => {
if (!types || types.length === 0) {
return;
}
// Populate the type filter dropdowns
const typeFilter = document.getElementById('typeFilter');
const exportTypeFilter = document.getElementById('exportTypeFilter');
// Clear existing options except the first one (All event types)
while (typeFilter.options.length > 1) {
typeFilter.remove(1);
}
while (exportTypeFilter.options.length > 1) {
exportTypeFilter.remove(1);
}
// Add new options
types.forEach(type => {
// Main filter dropdown
const option = document.createElement('option');
option.value = type;
option.textContent = type;
typeFilter.appendChild(option);
// Export filter dropdown
const exportOption = document.createElement('option');
exportOption.value = type;
exportOption.textContent = type;
exportTypeFilter.appendChild(exportOption);
});
})
.catch(error => {
console.error('Error loading event types:', error);
});
}
// Get theme colors for charts
function getThemeColors() {
const isDarkMode = document.documentElement.getAttribute('data-bs-theme') === 'dark';
// In dark mode, use higher contrast colors for text
const textColor = isDarkMode ?
'rgb(255, 255, 255)' : // White for dark mode for maximum contrast
getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-on-surface').trim();
// Use a more visible grid color in dark mode
const gridColor = isDarkMode ?
'rgba(255, 255, 255, 0.2)' : // Semi-transparent white for dark mode
getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-outline-variant').trim();
// Define bright, high-contrast colors for both dark and light modes
const chartColorsDark = [
'rgb(162, 201, 255)', // Light blue - primary
'rgb(193, 194, 248)', // Light purple - tertiary
'rgb(255, 180, 171)', // Light red - error
'rgb(72, 189, 84)', // Green - other
'rgb(25, 177, 212)', // Cyan - convert
'rgb(25, 101, 212)', // Blue - sign
'rgb(255, 120, 146)', // Pink - security
'rgb(104, 220, 149)', // Light green - convertto
'rgb(212, 172, 25)', // Yellow - image
'rgb(245, 84, 84)', // Red - advance
];
const chartColorsLight = [
'rgb(0, 96, 170)', // Blue - primary
'rgb(88, 90, 138)', // Purple - tertiary
'rgb(186, 26, 26)', // Red - error
'rgb(72, 189, 84)', // Green - other
'rgb(25, 177, 212)', // Cyan - convert
'rgb(25, 101, 212)', // Blue - sign
'rgb(255, 120, 146)', // Pink - security
'rgb(104, 220, 149)', // Light green - convertto
'rgb(212, 172, 25)', // Yellow - image
'rgb(245, 84, 84)', // Red - advance
];
return {
text: textColor,
grid: gridColor,
backgroundColor: getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-surface-container').trim(),
chartColors: isDarkMode ? chartColorsDark : chartColorsLight,
isDarkMode: isDarkMode
};
}
// Function to generate a palette of colors for charts
function getChartColors(count, opacity = 0.6) {
try {
// Use theme colors first
const themeColors = getThemeColors();
if (themeColors && themeColors.chartColors && themeColors.chartColors.length > 0) {
const result = [];
for (let i = 0; i < count; i++) {
// Get the raw color and add opacity
let color = themeColors.chartColors[i % themeColors.chartColors.length];
// If it's rgb() format, convert to rgba()
if (color.startsWith('rgb(')) {
color = color.replace('rgb(', '').replace(')', '');
result.push(`rgba(${color}, ${opacity})`);
} else {
// Just use the color directly
result.push(color);
}
}
return result;
}
} catch (e) {
console.warn('Error using theme colors, falling back to default colors', e);
}
// Base colors - a larger palette than the default
const colors = [
[54, 162, 235], // blue
[255, 99, 132], // red
[75, 192, 192], // teal
[255, 206, 86], // yellow
[153, 102, 255], // purple
[255, 159, 64], // orange
[46, 204, 113], // green
[231, 76, 60], // dark red
[52, 152, 219], // light blue
[155, 89, 182], // violet
[241, 196, 15], // dark yellow
[26, 188, 156], // turquoise
[230, 126, 34], // dark orange
[149, 165, 166], // light gray
[243, 156, 18], // amber
[39, 174, 96], // emerald
[211, 84, 0], // dark orange red
[22, 160, 133], // green sea
[41, 128, 185], // belize hole
[142, 68, 173] // wisteria
];
const result = [];
// Always use the same format regardless of color source
if (count > colors.length) {
// Generate colors algorithmically for large sets
for (let i = 0; i < count; i++) {
// Generate a color based on position in the hue circle (0-360)
const hue = (i * 360 / count) % 360;
const sat = 70 + Math.random() * 10; // 70-80%
const light = 50 + Math.random() * 10; // 50-60%
result.push(`hsla(${hue}, ${sat}%, ${light}%, ${opacity})`);
}
} else {
// Use colors from our palette but also return in hsla format for consistency
for (let i = 0; i < count; i++) {
const color = colors[i % colors.length];
result.push(`rgba(${color[0]}, ${color[1]}, ${color[2]}, ${opacity})`);
}
}
return result;
}
@@ -1,205 +0,0 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}"
xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{team.details.title}, header=#{team.details.header})}"></th:block>
<link rel="stylesheet" th:href="@{/css/modern-tables.css}">
</head>
<body>
<th:block th:insert="~{fragments/common :: game}"></th:block>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<div class="data-container">
<div class="data-panel">
<div class="data-header">
<h1 class="data-title">
<span class="data-icon">
<span class="material-symbols-rounded">group</span>
</span>
<span th:text="'Team: ' + ${team.name}">Team Name</span>
</h1>
</div>
<div class="data-body">
<div class="data-stats">
<div class="data-stat-card">
<div class="data-stat-label" th:text="#{team.totalMembers}">Total Members:</div>
<div class="data-stat-value" th:text="${teamUsers.size()}">1</div>
</div>
</div>
<!-- Alert Messages -->
<div th:if="${errorMessage}" class="alert alert-danger data-mb-3">
<span th:text="#{${errorMessage}}">Default message if not found</span>
</div>
<div class="data-actions data-actions-start">
<a th:href="@{'/teams'}" class="data-btn data-btn-secondary">
<span class="material-symbols-rounded">arrow_back</span>
<span th:text="#{team.back}">Back to Teams</span>
</a>
</div>
<div class="data-section-title" th:text="#{team.members}">Members</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th th:text="#{team.username}">Username</th>
<th th:text="#{team.role}">Role</th>
<th scope="col" th:title="${@runningProOrHigher} ? #{adminUserSettings.lastRequest} : #{proFeatures}"
class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
<th th:text="#{team.status}">Status</th>
</tr>
</thead>
<tbody>
<tr th:each="user : ${teamUsers}">
<td th:text="${user.id}">1</td>
<td th:text="${user.username}">username</td>
<td th:text="#{${user.roleName}}">Role</td>
<td
th:text="${@runningProOrHigher} ? (${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}) : #{team.hidden}">
2023-01-01 12:00:00</td>
<td>
<span th:if="${user.enabled}" class="data-status data-status-success">
<span class="material-symbols-rounded">person</span>
<span th:text="#{team.enabled}">Enabled</span>
</span>
<span th:unless="${user.enabled}" class="data-status data-status-danger">
<span class="material-symbols-rounded">person_off</span>
<span th:text="#{team.disabled}">Disabled</span>
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty state for when there are no team members -->
<div th:if="${teamUsers.empty}" class="data-empty">
<span class="material-symbols-rounded data-empty-icon">person_off</span>
<p class="data-empty-text" th:text="#{team.noMembers}">This team has no members yet.</p>
<button data-bs-toggle="modal" data-bs-target="#addUserToTeamModal" class="data-btn data-btn-primary">
<span class="material-symbols-rounded">person_add</span>
<span th:text="#{team.addUser}">Add User to Team</span>
</button>
</div>
<!-- Add button for non-empty teams too -->
<div th:if="${!teamUsers.empty}" class="data-actions data-mt-3">
<button data-bs-toggle="modal" data-bs-target="#addUserToTeamModal" class="data-btn data-btn-primary">
<span class="material-symbols-rounded">person_add</span>
<span th:text="#{team.addUser}">Add User to Team</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- JavaScript for team warning -->
<script th:inline="javascript">
function checkUserTeam(userId) {
// Clear any existing warning
const warningDiv = document.getElementById('teamChangeWarning');
const warningMessage = document.getElementById('warningMessage');
const submitButton = document.getElementById('addUserSubmitBtn');
// Reset
warningDiv.style.display = 'none';
submitButton.onclick = null;
// Get the selected option
const selectedOption = document.querySelector('#userId option[value="' + userId + '"]');
if (!selectedOption) return;
// Get team data
const currentTeam = selectedOption.getAttribute('data-team');
const currentTeamId = selectedOption.getAttribute('data-team-id');
const newTeamName = /*[[${team.name}]]*/ 'Current Team';
// If user is already in a team, show warning
if (currentTeam && currentTeam.length > 0) {
// Use internationalized message
const warningTemplate = /*[[#{team.warning.moveUser}]]*/ 'Warning: This will move the user from "{0}" team to "{1}" team. Are you sure?';
const formattedWarning = warningTemplate.replace('{0}', currentTeam).replace('{1}', newTeamName);
warningMessage.textContent = formattedWarning;
warningDiv.style.display = 'block';
// Add confirmation to submit button
submitButton.onclick = function (e) {
// Use internationalized message
const confirmTemplate = /*[[#{team.confirm.moveUser}]]*/ 'Are you sure you want to move this user from "{0}" team to "{1}" team?';
const formattedConfirm = confirmTemplate.replace('{0}', currentTeam).replace('{1}', newTeamName);
if (!confirm(formattedConfirm)) {
e.preventDefault();
return false;
}
return true;
};
}
}
</script>
<!-- Add User to Team Modal -->
<div class="modal fade" id="addUserToTeamModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form th:action="@{'/api/v1/team/addUser'}" method="post" class="modal-content data-modal">
<div class="data-modal-header">
<h5 class="data-modal-title">
<span class="data-icon">
<span class="material-symbols-rounded">person_add</span>
</span>
<span th:text="#{team.addUser}">Add User to Team</span>
</h5>
<button type="button" class="data-btn-close" data-bs-dismiss="modal" aria-label="Close">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="data-modal-body">
<input type="hidden" name="teamId" th:value="${team.id}" />
<div class="data-form-group">
<label for="userId" class="data-form-label" th:text="#{team.selectUser}">Select User</label>
<select name="userId" id="userId" class="data-form-control" required onchange="checkUserTeam(this.value)">
<option value="" disabled selected th:text="#{selectFilter}">-- Select User --</option>
<option th:each="user : ${availableUsers}" th:value="${user.id}" th:text="${user.username}"
th:data-team="${user.team != null ? user.team.name : ''}"
th:data-team-id="${user.team != null ? user.team.id : ''}">
Username
</option>
</select>
</div>
<!-- Warning message for users being moved between teams -->
<div id="teamChangeWarning" class="alert alert-warning mt-3" style="display: none;">
<span class="material-symbols-rounded">warning</span>
<span id="warningMessage">Warning: This will move the user from their current team to this team.</span>
</div>
<div class="data-form-actions">
<button type="button" class="data-btn data-btn-secondary" data-bs-dismiss="modal">
<span class="material-symbols-rounded">close</span>
<span th:text="#{cancel}">Cancel</span>
</button>
<button type="submit" id="addUserSubmitBtn" class="data-btn data-btn-primary">
<span class="material-symbols-rounded">check</span>
<span th:text="#{team.addUser}">Add User</span>
</button>
</div>
</div>
</form>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>
@@ -1,158 +0,0 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}"
xmlns:th="https://www.thymeleaf.org">
<head>
<th:block
th:insert="~{fragments/common :: head(title=#{adminUserSettings.manageTeams}, header=#{adminUserSettings.manageTeams})}">
</th:block>
<link rel="stylesheet" th:href="@{/css/modern-tables.css}">
</head>
<body>
<th:block th:insert="~{fragments/common :: game}"></th:block>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<div class="data-container">
<div class="data-panel">
<div class="data-header">
<h1 class="data-title">
<span class="data-icon">
<span class="material-symbols-rounded">groups</span>
</span>
<span th:text="#{adminUserSettings.manageTeams}">Team Management</span>
</h1>
</div>
<div class="data-body">
<!-- Back Button -->
<div class="data-actions data-actions-start">
<a th:href="@{'/adminSettings'}" class="data-btn data-btn-secondary">
<span class="material-symbols-rounded">arrow_back</span>
<span th:text="#{back.toSettings}">Back to Settings</span>
</a>
</div>
<!-- Alert Messages -->
<div th:if="${addMessage}" class="alert alert-success data-mb-3">
<span th:text="#{${addMessage}}">Default message if not found</span>
</div>
<div th:if="${changeMessage}" class="alert alert-success data-mb-3">
<span th:text="#{${changeMessage}}">Default message if not found</span>
</div>
<div th:if="${deleteMessage}" class="alert alert-danger data-mb-3">
<span th:text="#{${deleteMessage}}">Default message if not found</span>
</div>
<div th:if="${errorMessage}" class="alert alert-danger data-mb-3">
<span th:text="#{${errorMessage}}">Default message if not found</span>
</div>
<!-- Create New Team Button -->
<div class="data-actions">
<a href="#" th:data-bs-toggle="${@runningProOrHigher} ? 'modal' : null"
th:data-bs-target="${@runningProOrHigher} ? '#addTeamModal' : null"
th:class="${@runningProOrHigher} ? 'data-btn data-btn-primary' : 'data-btn data-btn-danger'"
th:title="${@runningProOrHigher} ? #{adminUserSettings.createTeam} : #{enterpriseEdition.proTeamFeatureDisabled}">
<span class="material-symbols-rounded">group_add</span>
<span th:text="#{adminUserSettings.createTeam}">Create New Team</span>
</a>
</div>
<!-- Team Table -->
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th scope="col" th:text="#{adminUserSettings.teamName}">Team Name</th>
<th scope="col" th:text="#{adminUserSettings.totalMembers}">Total Members</th>
<th scope="col" th:title="${@runningProOrHigher} ? #{adminUserSettings.lastRequest} : #{proFeatures}"
class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
<th scope="col" th:text="#{adminUserSettings.actions}">Actions</th>
</tr>
</thead>
<tbody>
<!-- Try approach 1 - DTO projection -->
<tr th:each="teamDto : ${teamsWithCounts}">
<td th:text="${teamDto.name}"></td>
<td th:text="${teamDto.userCount}"></td>
<td
th:text="${@runningProOrHigher} ? (${teamLastRequest[teamDto.id] != null ? #dates.format(teamLastRequest[teamDto.id], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}) : #{adminUserSettings.teamHidden}">
</td>
<td>
<div class="data-action-cell">
<a th:href="@{'/teams/' + ${teamDto.id}}" class="data-btn data-btn-secondary data-btn-sm"
th:title="#{adminUserSettings.viewTeam}">
<span class="material-symbols-rounded">search</span> <span th:text="#{view}">View</span>
</a>
<form th:action="@{'/api/v1/team/delete'}" method="post" style="display:inline-block"
onsubmit="return confirmDeleteTeam()">
<input type="hidden" name="teamId" th:value="${teamDto.id}" />
<button type="submit" class="data-btn data-btn-danger data-btn-sm"
th:disabled="${!@runningProOrHigher}" th:classappend="${!@runningProOrHigher} ? 'disabled' : ''"
th:title="${@runningProOrHigher} ? #{adminUserSettings.deleteTeam} : #{enterpriseEdition.proTeamFeatureDisabled}">
<span class="material-symbols-rounded">delete</span> <span th:text="#{delete}">Delete</span>
</button>
</form>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Delete Confirmation Script -->
<script th:inline="javascript">
const confirmDeleteText = /*[[#{adminUserSettings.confirmDeleteTeam}]]*/ 'Are you sure you want to delete this team?';
function confirmDeleteTeam() {
return confirm(confirmDeleteText);
}
</script>
</div>
</div>
</div>
<!-- Add Team Modal -->
<div class="modal fade" id="addTeamModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form th:action="@{'/api/v1/team/create'}" method="post" class="modal-content data-modal">
<div class="data-modal-header">
<h5 class="data-modal-title">
<span class="data-icon">
<span class="material-symbols-rounded">group_add</span>
</span>
<span th:text="#{adminUserSettings.createTeam}">Create Team</span>
</h5>
<button type="button" class="data-btn-close" data-bs-dismiss="modal" aria-label="Close">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="data-modal-body">
<div class="data-form-group">
<label for="teamName" class="data-form-label" th:text="#{adminUserSettings.teamName}">Team Name</label>
<input type="text" name="name" id="teamName" class="data-form-control" required />
</div>
<div class="data-form-actions">
<button type="button" class="data-btn data-btn-secondary" data-bs-dismiss="modal">
<span class="material-symbols-rounded">close</span>
<span th:text="#{cancel}">Cancel</span>
</button>
<button type="submit" class="data-btn data-btn-primary">
<span class="material-symbols-rounded">check</span>
<span th:text="#{adminUserSettings.createTeam}">Create</span>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>
@@ -1,383 +0,0 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title='Audit Dashboard', header='Audit Dashboard')}"></th:block>
<!-- Include Chart.js for visualizations -->
<script th:src="@{/js/thirdParty/chart.umd.min.js}"></script>
<!-- Include custom CSS -->
<link rel="stylesheet" th:href="@{/css/audit-dashboard.css}" />
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<div class="container-fluid mt-4">
<h1 class="mb-4" th:text="#{audit.dashboard.title}">Audit Dashboard</h1>
<!-- System Status Card -->
<div class="card dashboard-card mb-4">
<div class="card-header">
<h2 class="h5 mb-0" th:text="#{audit.dashboard.systemStatus}">Audit System Status</h2>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label" th:text="#{audit.dashboard.status}">Status</div>
<div class="stat-number">
<span th:if="${auditEnabled}" class="text-success" th:text="#{audit.dashboard.enabled}">Enabled</span>
<span th:unless="${auditEnabled}" class="text-danger" th:text="#{audit.dashboard.disabled}">Disabled</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label" th:text="#{audit.dashboard.currentLevel}">Current Level</div>
<div class="stat-number">
<span th:class="'level-indicator level-' + ${auditLevelInt}" th:text="${auditLevel}">STANDARD</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label" th:text="#{audit.dashboard.retentionPeriod}">Retention Period</div>
<div class="stat-number" th:text="${retentionDays} + ' ' + #{audit.dashboard.days}">90 days</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label" th:text="#{audit.dashboard.totalEvents}">Total Events</div>
<div class="stat-number" id="total-events">-</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tabs for different sections -->
<ul class="nav nav-tabs" id="auditTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="dashboard-tab" data-bs-toggle="tab" data-bs-target="#dashboard" type="button" role="tab" aria-controls="dashboard" aria-selected="true" th:text="#{audit.dashboard.tab.dashboard}">Dashboard</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="events-tab" data-bs-toggle="tab" data-bs-target="#events" type="button" role="tab" aria-controls="events" aria-selected="false" th:text="#{audit.dashboard.tab.events}">Audit Events</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="export-tab" data-bs-toggle="tab" data-bs-target="#export" type="button" role="tab" aria-controls="export" aria-selected="false" th:text="#{audit.dashboard.tab.export}">Export</button>
</li>
<li class="nav-item" role="presentation">
</li>
</ul>
<div class="tab-content" id="auditTabsContent">
<!-- Dashboard Tab -->
<div class="tab-pane fade show active" id="dashboard" role="tabpanel" aria-labelledby="dashboard-tab">
<div class="row mt-4">
<div class="col-md-6">
<div class="card dashboard-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsByType}">Events by Type</h3>
<div class="btn-group">
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(7)" th:text="#{audit.dashboard.period.7days}">7 Days</button>
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(30)" th:text="#{audit.dashboard.period.30days}">30 Days</button>
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(90)" th:text="#{audit.dashboard.period.90days}">90 Days</button>
</div>
</div>
<div class="card-body">
<div class="chart-container position-relative">
<div class="loading-overlay" id="type-chart-loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
</div>
</div>
<canvas id="typeChart"></canvas>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card dashboard-card">
<div class="card-header">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsByUser}">Events by User</h3>
</div>
<div class="card-body">
<div class="chart-container position-relative">
<div class="loading-overlay" id="user-chart-loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
</div>
</div>
<canvas id="userChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card dashboard-card">
<div class="card-header">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsOverTime}">Events Over Time</h3>
</div>
<div class="card-body">
<div class="chart-container position-relative">
<div class="loading-overlay" id="time-chart-loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
</div>
</div>
<canvas id="timeChart"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Events Tab -->
<div class="tab-pane fade" id="events" role="tabpanel" aria-labelledby="events-tab">
<div class="card dashboard-card mt-4">
<div class="card-header">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.auditEvents}">Audit Events</h3>
</div>
<div class="card-body">
<!-- Filters -->
<div class="card filter-card">
<div class="row">
<div class="col-md-3">
<div class="mb-3">
<label for="typeFilter" class="form-label" th:text="#{audit.dashboard.filter.eventType}">Event Type</label>
<select class="form-select" id="typeFilter">
<option value="" th:text="#{audit.dashboard.filter.allEventTypes}">All event types</option>
<!-- Will be populated from API -->
</select>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="principalFilter" class="form-label" th:text="#{audit.dashboard.filter.user}">User</label>
<input type="text" class="form-control" id="principalFilter" th:placeholder="#{audit.dashboard.filter.userPlaceholder}" placeholder="Filter by user">
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="startDateFilter" class="form-label" th:text="#{audit.dashboard.filter.startDate}">Start Date</label>
<input type="date" class="form-control" id="startDateFilter">
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="endDateFilter" class="form-label" th:text="#{audit.dashboard.filter.endDate}">End Date</label>
<input type="date" class="form-control" id="endDateFilter">
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<button id="applyFilters" class="btn btn-primary" th:text="#{audit.dashboard.filter.apply}">Apply Filters</button>
<button id="resetFilters" class="btn btn-secondary" th:text="#{reset}">Reset</button>
</div>
</div>
</div>
<!-- Event Table -->
<div class="table-responsive position-relative">
<div class="loading-overlay" id="table-loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
</div>
</div>
<table class="table table-striped table-hover audit-table">
<thead>
<tr>
<th th:text="#{audit.dashboard.table.id}">ID</th>
<th th:text="#{audit.dashboard.table.time}">Time</th>
<th th:text="#{audit.dashboard.table.user}">User</th>
<th th:text="#{audit.dashboard.table.type}">Type</th>
<th th:text="#{audit.dashboard.table.details}">Details</th>
</tr>
</thead>
<tbody id="auditTableBody">
<!-- Table rows will be populated by JavaScript -->
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination-container">
<div>
<span th:text="#{audit.dashboard.pagination.show}">Show</span>
<select id="pageSizeSelect" class="form-select form-select-sm d-inline-block w-auto mx-2">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<span th:text="#{audit.dashboard.pagination.entries}">entries</span>
<span class="mx-3" th:text="#{audit.dashboard.pagination.pageInfo1}">Page </span><span id="currentPage">1</span> <span th:text="#{audit.dashboard.pagination.pageInfo2}">of</span> <span id="totalPages">1</span> (<span th:text="#{audit.dashboard.pagination.totalRecords}">Total records:</span> <span id="totalRecords">0</span>)
</div>
<nav aria-label="Audit events pagination">
<div class="btn-group" role="group" aria-label="Pagination">
<button type="button" class="btn btn-outline-primary" id="page-first">&laquo;</button>
<button type="button" class="btn btn-outline-primary" id="page-prev">&lsaquo;</button>
<span class="btn btn-outline-secondary disabled" id="page-indicator">Page 1 of 1</span>
<button type="button" class="btn btn-outline-primary" id="page-next">&rsaquo;</button>
<button type="button" class="btn btn-outline-primary" id="page-last">&raquo;</button>
</div>
</nav>
</div>
</div>
</div>
<!-- Event Details Modal -->
<div class="modal fade" id="eventDetailsModal" tabindex="-1" aria-labelledby="eventDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="eventDetailsModalLabel" th:text="#{audit.dashboard.modal.eventDetails}">Event Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" th:aria-label="#{close}" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-4">
<strong th:text="#{audit.dashboard.modal.id} + ':'">ID:</strong> <span id="modal-id"></span>
</div>
<div class="col-md-4">
<strong th:text="#{audit.dashboard.modal.user} + ':'">User:</strong> <span id="modal-principal"></span>
</div>
<div class="col-md-4">
<strong th:text="#{audit.dashboard.modal.type} + ':'">Type:</strong> <span id="modal-type"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-md-12">
<strong th:text="#{audit.dashboard.modal.time} + ':'">Time:</strong> <span id="modal-timestamp"></span>
</div>
</div>
<div class="row">
<div class="col-md-12">
<strong th:text="#{audit.dashboard.modal.data} + ':'">Data:</strong>
<div class="json-viewer" id="modal-data"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" th:text="#{close}">Close</button>
</div>
</div>
</div>
</div>
</div>
<!-- Export Tab -->
<div class="tab-pane fade" id="export" role="tabpanel" aria-labelledby="export-tab">
<div class="card dashboard-card mt-4">
<div class="card-header">
<h3 class="h5 mb-0" th:text="#{audit.dashboard.export.title}">Export Audit Data</h3>
</div>
<div class="card-body">
<!-- Export Filters -->
<div class="card filter-card">
<div class="row">
<div class="col-md-3">
<div class="mb-3">
<label for="exportTypeFilter" class="form-label" th:text="#{audit.dashboard.filter.eventType}">Event Type</label>
<select class="form-select" id="exportTypeFilter">
<option value="" th:text="#{audit.dashboard.filter.allEventTypes}">All event types</option>
<!-- Will be populated from API -->
</select>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="exportPrincipalFilter" class="form-label" th:text="#{audit.dashboard.filter.user}">User</label>
<input type="text" class="form-control" id="exportPrincipalFilter" th:placeholder="#{audit.dashboard.filter.userPlaceholder}" placeholder="Filter by user">
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="exportStartDateFilter" class="form-label" th:text="#{audit.dashboard.filter.startDate}">Start Date</label>
<input type="date" class="form-control" id="exportStartDateFilter">
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="exportEndDateFilter" class="form-label" th:text="#{audit.dashboard.filter.endDate}">End Date</label>
<input type="date" class="form-control" id="exportEndDateFilter">
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-6">
<h5 th:text="#{audit.dashboard.export.format}">Export Format</h5>
<div>
<label class="btn btn-outline-primary" style="margin-right: 10px;">
<input type="radio" name="exportFormat" id="formatCSV" value="csv" checked style="margin-right: 5px;">
<span th:text="#{audit.dashboard.export.csv}">CSV (Comma Separated Values)</span>
</label>
<label class="btn btn-outline-primary">
<input type="radio" name="exportFormat" id="formatJSON" value="json" style="margin-right: 5px;">
<span th:text="#{audit.dashboard.export.json}">JSON (JavaScript Object Notation)</span>
</label>
</div>
</div>
<div class="col-md-6">
<button id="exportButton" class="btn btn-primary mt-4">
<i class="bi bi-download"></i> <span th:text="#{audit.dashboard.export.button}">Export Data</span>
</button>
<button id="resetExportFilters" class="btn btn-secondary mt-4 ms-2">
<span th:text="#{audit.dashboard.filter.reset}">Reset Filters</span>
</button>
</div>
</div>
</div>
<div class="alert alert-info mt-3">
<h5 th:text="#{audit.dashboard.export.infoTitle}">Export Information</h5>
<p th:text="#{audit.dashboard.export.infoDesc1}">The export will include all audit events matching the selected filters. For large datasets, the export may take a few moments to generate.</p>
<p th:text="#{audit.dashboard.export.infoDesc2}">Exported data will include:</p>
<ul>
<li th:text="#{audit.dashboard.export.infoItem1}">Event ID</li>
<li th:text="#{audit.dashboard.export.infoItem2}">User</li>
<li th:text="#{audit.dashboard.export.infoItem3}">Event Type</li>
<li th:text="#{audit.dashboard.export.infoItem4}">Timestamp</li>
<li th:text="#{audit.dashboard.export.infoItem5}">Event Data</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS is loaded by the common fragments -->
<script th:src="@{/js/thirdParty/jquery.min.js}"></script>
<script th:src="@{/js/thirdParty/bootstrap.min.js}"></script>
<!-- Internationalization data for JavaScript -->
<script th:inline="javascript">
window.i18n = {
loading: /*[[#{loading}]]*/ 'Loading...',
noEventsFound: /*[[#{audit.dashboard.js.noEventsFound}]]*/ 'No audit events found matching the current filters',
errorLoading: /*[[#{audit.dashboard.js.errorLoading}]]*/ 'Error loading data:',
errorRendering: /*[[#{audit.dashboard.js.errorRendering}]]*/ 'Error rendering table:',
loadingPage: /*[[#{audit.dashboard.js.loadingPage}]]*/ 'Loading page',
eventsByType: /*[[#{audit.dashboard.eventsByType}]]*/ 'Events by Type',
eventsByUser: /*[[#{audit.dashboard.eventsByUser}]]*/ 'Events by User',
eventsOverTime: /*[[#{audit.dashboard.eventsOverTime}]]*/ 'Events Over Time',
viewDetails: /*[[#{audit.dashboard.table.viewDetails}]]*/ 'View Details'
};
</script>
<!-- Load custom JavaScript -->
<script th:src="@{/js/audit/dashboard.js}"></script>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>