Add HTMX modal upload and cert table refresh

Enable HTMX-driven certificate uploads and partial UI updates for LoTW. Updated Lotw controller to detect HTMX requests in do_cert_upload, return inline success/error alerts for modal uploads, and added cert_table_refresh endpoint to return the cert table partial. Extracted the certificate table into a new view (lotw_views/cert_table.php) and replaced the inline table in index.php with a container that loads that partial. Added a Bootstrap modal view (upload_cert_modal.php) with an HTMX multipart form, drag-and-drop file UI, JS handlers to manage upload state and refresh the certificate list, and modal reset behavior. Minor refactors to flash/success handling to support both full-page and HTMX flows.
This commit is contained in:
Peter Goodhall 2026-02-18 15:51:19 +00:00
parent 7f7d2a6426
commit 88e95b0db9
4 changed files with 389 additions and 128 deletions

View file

@ -93,6 +93,7 @@ class Lotw extends CI_Controller {
|
| do_cert_upload is called from cert_upload form submit and handles uploading
| and processing of p12 files and storing the data into mysql
| Now supports HTMX requests for modal-based uploads
|
*/
public function do_cert_upload()
@ -106,6 +107,9 @@ class Lotw extends CI_Controller {
echo "You must install php OpenSSL for LoTW functions to work";
}
// Check if this is an HTMX request
$is_htmx = $this->input->get_request_header('HX-Request');
// create folder to store certs while processing
if (!file_exists('./uploads/lotw/certs')) {
mkdir('./uploads/lotw/certs', 0755, true);
@ -119,17 +123,21 @@ class Lotw extends CI_Controller {
if ( ! $this->upload->do_upload('userfile'))
{
// Upload of P12 Failed
$error = array('error' => $this->upload->display_errors());
$error = $this->upload->display_errors();
// Load DXCC Countrys List
// If HTMX request, return just the error message
if($is_htmx) {
echo '<div class="alert alert-danger" role="alert">' . $error . '</div>';
return;
}
// Otherwise load the full form page
$data = array('error' => $error);
$data['dxcc_list'] = $this->dxcc->list();
// Set Page Title
$data['page_title'] = "Logbook of the World";
// Load Views
$this->load->view('interface_assets/header', $data);
$this->load->view('lotw_views/upload_cert', $error);
$this->load->view('lotw_views/upload_cert', $data);
$this->load->view('interface_assets/footer');
}
else
@ -152,20 +160,28 @@ class Lotw extends CI_Controller {
$this->LotwCert->store_certificate($this->session->userdata('user_id'), $info['issued_callsign'], $info['dxcc-id'], $info['validFrom'], $info['validTo_Date'], $info['qso-first-date'], $info['qso-end-date'], $info['pem_key'], $info['general_cert']);
// Cert success flash message
$this->session->set_flashdata('Success', $info['issued_callsign'].' Certificate Imported.');
$success_msg = $info['issued_callsign'].' Certificate Imported.';
} else {
// Certificate is in the system time to update
$this->LotwCert->update_certificate($this->session->userdata('user_id'), $info['issued_callsign'], $info['dxcc-id'], $info['validFrom'], $info['validTo_Date'], $info['qso-first-date'], $info['qso-end-date'], $info['pem_key'], $info['general_cert']);
// Cert success flash message
$this->session->set_flashdata('Success', $info['issued_callsign'].' Certificate Updated.');
$success_msg = $info['issued_callsign'].' Certificate Updated.';
}
// p12 certificate processed time to delete the file
unlink($data['upload_data']['full_path']);
// If HTMX request, return just the success message
if($is_htmx) {
echo '<div class="alert alert-success" role="alert">' . $success_msg . '</div>';
return;
}
// Otherwise load the full page
$this->session->set_flashdata('Success', $success_msg);
// Get Array of the logged in users LoTW certs.
$data['lotw_cert_results'] = $this->LotwCert->lotw_certs($this->session->userdata('user_id'));
@ -176,12 +192,31 @@ class Lotw extends CI_Controller {
$this->load->view('interface_assets/header', $data);
$this->load->view('lotw_views/index');
$this->load->view('interface_assets/footer');
}
}
/*
|--------------------------------------------------------------------------
| Function: cert_table_refresh
|--------------------------------------------------------------------------
|
| Returns the certificate table for HTMX refresh after upload
|
*/
public function cert_table_refresh() {
$this->load->model('user_model');
if(!$this->user_model->authorize(2)) { $this->session->set_flashdata('notice', 'You\'re not allowed to do that!'); redirect('dashboard'); }
// Load model
$this->load->model('LotwCert');
// Get Array of the logged in users LoTW certs.
$data['lotw_cert_results'] = $this->LotwCert->lotw_certs($this->session->userdata('user_id'));
// Load just the cert table partial
$this->load->view('lotw_views/cert_table', $data);
}
/*
|--------------------------------------------------------------------------
| Function: lotw_upload

View file

@ -0,0 +1,112 @@
<?php if(isset($error)) { ?>
<div class="alert alert-danger" role="alert">
<?php echo $error; ?>
</div>
<?php } ?>
<?php if(isset($_SESSION['Success'])) { ?>
<div class="alert alert-success" role="alert">
<?php echo $_SESSION['Success']; ?>
</div>
<?php unset($_SESSION['Success']); ?>
<?php } ?>
<?php if ($lotw_cert_results->num_rows() > 0) { ?>
<div class="table-responsive">
<table class="table table-hover">
<thead class="thead-light">
<tr>
<th scope="col"><?php echo lang('gen_hamradio_callsign'); ?></th>
<th scope="col"><?php echo lang('gen_hamradio_dxcc'); ?></th>
<th scope="col"><?php echo lang('lotw_qso_start_date'); ?></th>
<th scope="col"><?php echo lang('lotw_qso_end_date'); ?></th>
<th scope="col"><?php echo lang('lotw_date_created'); ?></th>
<th scope="col"><?php echo lang('lotw_date_expires'); ?></th>
<th scope="col"><?php echo lang('lotw_status'); ?></th>
<th scope="col"><?php echo lang('lotw_options'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($lotw_cert_results->result() as $row) { ?>
<tr>
<td><?php echo $row->callsign; ?></td>
<td><?php echo $row->cert_dxcc == '' ? '- NONE -' : ucfirst($row->cert_dxcc); if ($row->cert_dxcc_end != NULL) { echo ' <span class="badge text-bg-danger">'.lang('gen_hamradio_deleted_dxcc').'</span>'; } ?></td>
<td><?php
if (isset($row->qso_start_date)) {
$valid_qso_start = strtotime( $row->qso_start_date );
$new_valid_qso_start = date($this->config->item('qso_date_format'), $valid_qso_start );
echo $new_valid_qso_start;
} else {
echo "n/a";
} ?>
</td>
<td><?php
if (isset($row->qso_end_date)) {
$valid_qso_end = strtotime( $row->qso_end_date );
$new_valid_qso_end = date($this->config->item('qso_date_format'), $valid_qso_end );
echo $new_valid_qso_end;
} else {
echo "n/a";
} ?>
</td>
<td><?php
$valid_from = strtotime( $row->date_created );
$new_valid_from = date($this->config->item('qso_date_format'), $valid_from );
echo $new_valid_from; ?>
</td>
<td>
<?php
$valid_to = strtotime( $row->date_expires );
$new_valid_to = date($this->config->item('qso_date_format'), $valid_to );
echo $new_valid_to; ?>
</td>
<td>
<?php $current_date = date('Y-m-d H:i:s'); ?>
<?php $warning_date = date('Y-m-d H:i:s', strtotime($row->date_expires.'-30 days')); ?>
<?php if ($row->archived) { ?>
<span class="badge text-bg-secondary"><?php echo 'Archived'; ?></span>
<?php } else { ?>
<?php if ($current_date > $row->date_expires) { ?>
<span class="badge text-bg-danger"><?php echo lang('lotw_expired'); ?></span>
<?php } else if ($current_date <= $row->date_expires && $current_date > $warning_date) { ?>
<span class="badge text-bg-warning"><?php echo lang('lotw_expiring'); ?></span>
<?php } else { ?>
<span class="badge text-bg-success"><?php echo lang('lotw_valid'); ?></span>
<?php } ?>
<?php } ?>
<?php if ($row->last_upload) {
$last_upload = date($this->config->item('qso_date_format').' H:i:s', strtotime( $row->last_upload )); ?>
<span class="badge text-bg-success"><?php echo $last_upload; ?></span>
<?php } else { ?>
<span class="badge text-bg-warning"><?php echo lang('lotw_not_synced'); ?></span>
<?php } ?>
</td>
<td>
<?php if ($row->archived) { ?>
<a class="btn btn-outline-info btn-sm me-1" href="<?php echo site_url('lotw/toggle_archive_cert/'.$row->lotw_cert_id); ?>" role="button">
<i class="fas fa-box-open"></i> Unarchive
</a>
<?php } else { ?>
<a class="btn btn-outline-secondary btn-sm me-1" href="<?php echo site_url('lotw/toggle_archive_cert/'.$row->lotw_cert_id); ?>" role="button">
<i class="fas fa-archive"></i> Archive
</a>
<?php } ?>
<a class="btn btn-outline-danger btn-sm" href="<?php echo site_url('lotw/delete_cert/'.$row->lotw_cert_id); ?>" role="button"><i class="far fa-trash-alt"></i> <?php echo lang('lotw_btn_delete'); ?></a>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php } else { ?>
<div class="alert alert-info" role="alert">
<?php echo lang('lotw_no_certs_uploaded'); ?>
</div>
<?php } ?>

View file

@ -6,129 +6,19 @@
<!-- Card Starts -->
<div class="card">
<div class="card-header">
<a class="btn btn-outline-success btn-sm float-end" href="<?php echo site_url('/lotw/cert_upload'); ?>" role="button"><i class="fas fa-cloud-upload-alt"></i> <?php echo lang('lotw_btn_upload_certificate'); ?></a><i class="fab fa-expeditedssl"></i> <?php echo lang('lotw_title_available_cert'); ?>
<button class="btn btn-outline-success btn-sm float-end" data-bs-toggle="modal" data-bs-target="#uploadCertModal" role="button"><i class="fas fa-cloud-upload-alt"></i> <?php echo lang('lotw_btn_upload_certificate'); ?></button><i class="fab fa-expeditedssl"></i> <?php echo lang('lotw_title_available_cert'); ?>
</div>
<div class="lotw-cert-list">
<?php if(isset($error)) { ?>
<div class="alert alert-danger" role="alert">
<?php echo $error; ?>
</div>
<?php } ?>
<?php if(isset($_SESSION['Success'])) { ?>
<div class="alert alert-success" role="alert">
<?php echo $_SESSION['Success']; ?>
</div>
<?php } ?>
<?php if ($lotw_cert_results->num_rows() > 0) { ?>
<div class="table-responsive">
<table class="table table-hover">
<thead class="thead-light">
<tr>
<th scope="col"><?php echo lang('gen_hamradio_callsign'); ?></th>
<th scope="col"><?php echo lang('gen_hamradio_dxcc'); ?></th>
<th scope="col"><?php echo lang('lotw_qso_start_date'); ?></th>
<th scope="col"><?php echo lang('lotw_qso_end_date'); ?></th>
<th scope="col"><?php echo lang('lotw_date_created'); ?></th>
<th scope="col"><?php echo lang('lotw_date_expires'); ?></th>
<th scope="col"><?php echo lang('lotw_status'); ?></th>
<th scope="col"><?php echo lang('lotw_options'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($lotw_cert_results->result() as $row) { ?>
<tr>
<td><?php echo $row->callsign; ?></td>
<td><?php echo $row->cert_dxcc == '' ? '- NONE -' : ucfirst($row->cert_dxcc); if ($row->cert_dxcc_end != NULL) { echo ' <span class="badge text-bg-danger">'.lang('gen_hamradio_deleted_dxcc').'</span>'; } ?></td>
<td><?php
if (isset($row->qso_start_date)) {
$valid_qso_start = strtotime( $row->qso_start_date );
$new_valid_qso_start = date($this->config->item('qso_date_format'), $valid_qso_start );
echo $new_valid_qso_start;
} else {
echo "n/a";
} ?>
</td>
<td><?php
if (isset($row->qso_end_date)) {
$valid_qso_end = strtotime( $row->qso_end_date );
$new_valid_qso_end = date($this->config->item('qso_date_format'), $valid_qso_end );
echo $new_valid_qso_end;
} else {
echo "n/a";
} ?>
</td>
<td><?php
$valid_from = strtotime( $row->date_created );
$new_valid_from = date($this->config->item('qso_date_format'), $valid_from );
echo $new_valid_from; ?>
</td>
<td>
<?php
$valid_to = strtotime( $row->date_expires );
$new_valid_to = date($this->config->item('qso_date_format'), $valid_to );
echo $new_valid_to; ?>
</td>
<td>
<?php $current_date = date('Y-m-d H:i:s'); ?>
<?php $warning_date = date('Y-m-d H:i:s', strtotime($row->date_expires.'-30 days')); ?>
<?php if ($row->archived) { ?>
<span class="badge text-bg-secondary"><?php echo 'Archived'; ?></span>
<?php } else { ?>
<?php if ($current_date > $row->date_expires) { ?>
<span class="badge text-bg-danger"><?php echo lang('lotw_expired'); ?></span>
<?php } else if ($current_date <= $row->date_expires && $current_date > $warning_date) { ?>
<span class="badge text-bg-warning"><?php echo lang('lotw_expiring'); ?></span>
<?php } else { ?>
<span class="badge text-bg-success"><?php echo lang('lotw_valid'); ?></span>
<?php } ?>
<?php } ?>
<?php if ($row->last_upload) {
$last_upload = date($this->config->item('qso_date_format').' H:i:s', strtotime( $row->last_upload )); ?>
<span class="badge text-bg-success"><?php echo $last_upload; ?></span>
<?php } else { ?>
<span class="badge text-bg-warning"><?php echo lang('lotw_not_synced'); ?></span>
<?php } ?>
</td>
<td>
<?php if ($row->archived) { ?>
<a class="btn btn-outline-info btn-sm me-1" href="<?php echo site_url('lotw/toggle_archive_cert/'.$row->lotw_cert_id); ?>" role="button">
<i class="fas fa-box-open"></i> Unarchive
</a>
<?php } else { ?>
<a class="btn btn-outline-secondary btn-sm me-1" href="<?php echo site_url('lotw/toggle_archive_cert/'.$row->lotw_cert_id); ?>" role="button">
<i class="fas fa-archive"></i> Archive
</a>
<?php } ?>
<a class="btn btn-outline-danger btn-sm" href="<?php echo site_url('lotw/delete_cert/'.$row->lotw_cert_id); ?>" role="button"><i class="far fa-trash-alt"></i> <?php echo lang('lotw_btn_delete'); ?></a>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php } else { ?>
<div class="alert alert-info" role="alert">
<?php echo lang('lotw_no_certs_uploaded'); ?>
</div>
<?php } ?>
</div>
<div class="lotw-cert-list" id="lotw-cert-list">
<?php $this->load->view('lotw_views/cert_table', array('lotw_cert_results' => $lotw_cert_results)); ?>
</div>
</div>
<!-- Card Ends -->
<br>
<!-- Card Starts -->
<!-- Information Card - Only show if certificates exist -->
<?php if ($lotw_cert_results->num_rows() > 0) { ?>
<div class="card">
<div class="card-header">
<?php echo lang('lotw_title_information'); ?>
@ -146,9 +36,13 @@
<div id="lotw_manual_results"></div>
</div>
</div>
<?php } ?>
</div>
<!-- Load Certificate Upload Modal -->
<?php $this->load->view('lotw_views/upload_cert_modal'); ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
const syncBtn = document.getElementById('manual-sync-btn');

View file

@ -0,0 +1,220 @@
<!-- LOTW Certificate Upload Modal -->
<div class="modal fade" id="uploadCertModal" tabindex="-1" aria-labelledby="uploadCertModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header border-bottom">
<h5 class="modal-title" id="uploadCertModalLabel">
<i class="fas fa-cloud-upload-alt me-2"></i><?php echo lang('lotw_btn_upload_certificate'); ?>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Success/Error Messages -->
<div id="cert-upload-messages" class="mb-3"></div>
<!-- Upload Container -->
<div id="uploadContainer">
<!-- Instructions Card -->
<div class="alert alert-info" role="alert">
<h6 class="alert-heading"><i class="fas fa-info-circle me-2"></i><?php echo lang('lotw_title_export_p12_file_instruction'); ?></h6>
<ol class="mb-0 ps-3">
<li><?php echo lang('lotw_p12_export_step_one'); ?></li>
<li><?php echo lang('lotw_p12_export_step_two'); ?></li>
<li><?php echo lang('lotw_p12_export_step_three'); ?></li>
<li><?php echo lang('lotw_p12_export_step_four'); ?></li>
</ol>
</div>
<!-- Upload Form -->
<form id="certUploadForm"
hx-post="<?php echo site_url('lotw/do_cert_upload'); ?>"
hx-encoding='multipart/form-data'
hx-target="#cert-upload-messages"
hx-swap="innerHTML">
<div class="mb-4">
<label for="userfile" class="form-label fw-semibold"><?php echo lang('lotw_title_upload_p12_cert'); ?></label>
<!-- Drag & Drop Zone -->
<div id="dropZone" class="border-2 border-dashed border-primary rounded p-4 text-center bg-light mb-3 cursor-pointer" style="border-width: 2px !important; transition: all 0.3s ease;">
<i class="fas fa-file-import text-primary fa-2x mb-2 d-block"></i>
<p class="mb-2 text-muted">Drag and drop your .p12 file here</p>
<p class="mb-0 text-muted small">or click to browse</p>
</div>
<input type="file" name="userfile" id="userfile" accept=".p12" required style="display: none; visibility: hidden; height: 0; width: 0; margin: 0; padding: 0; border: none;">
<small class="form-text text-muted d-block mt-2"><i class="fas fa-file-certificate me-1"></i>Only .p12 certificate files are accepted</small>
</div>
</form>
</div>
</div>
<div class="modal-footer border-top">
<button type="submit" id="certUploadBtn" form="certUploadForm" class="btn btn-primary">
<i class="fas fa-cloud-upload-alt me-2"></i><?php echo lang('lotw_btn_upload_file'); ?>
</button>
</div>
</div>
</div>
</div>
<script>
// Handle upload start
document.addEventListener('htmx:beforeRequest', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'certUploadForm') {
const btn = document.getElementById('certUploadBtn');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Uploading...';
}
}
}, true);
// Handle upload complete - using document-level delegation
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'cert-upload-messages') {
handleUploadSuccess();
}
}, true);
// Handle upload success
function handleUploadSuccess() {
const messagesDiv = document.getElementById('cert-upload-messages');
const btn = document.getElementById('certUploadBtn');
const uploadContainer = document.getElementById('uploadContainer');
// Reset button
if (btn) {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-cloud-upload-alt me-2"></i><?php echo lang('lotw_btn_upload_file'); ?>';
}
// Check for any alert message
if (messagesDiv) {
const hasDangerAlert = messagesDiv.querySelector('.alert-danger');
const hasSuccessAlert = messagesDiv.querySelector('.alert-success');
const hasInfoAlert = messagesDiv.querySelector('.alert-info');
// If there's a success or info alert (not error), hide the form
if ((hasSuccessAlert || hasInfoAlert) && !hasDangerAlert) {
// Refresh the certificate table
htmx.ajax('GET', '<?php echo site_url('lotw/cert_table_refresh'); ?>', '#lotw-cert-list');
// Hide the upload container and button
if (uploadContainer) {
uploadContainer.style.display = 'none';
}
if (btn) {
btn.style.display = 'none';
}
// Add a completion message
const completionMsg = document.createElement('div');
completionMsg.className = 'alert alert-info alert-dismissible fade show mt-3';
completionMsg.innerHTML = '<i class="fas fa-info-circle me-2"></i><strong>Certificate uploaded successfully!</strong> The certificate table has been updated. You can close this modal.';
messagesDiv.appendChild(completionMsg);
}
}
}
// Setup drag and drop - no DOMContentLoaded needed
function setupDragDrop() {
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('userfile');
if (!dropZone || !fileInput) {
setTimeout(setupDragDrop, 100);
return;
}
// Click to browse
dropZone.addEventListener('click', () => fileInput.click(), { once: false });
// Drag over
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.style.opacity = '0.7';
dropZone.style.backgroundColor = 'rgba(13, 110, 253, 0.1)';
});
// Drag leave
dropZone.addEventListener('dragleave', () => {
dropZone.style.opacity = '1';
dropZone.style.backgroundColor = '';
});
// Drop
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.opacity = '1';
dropZone.style.backgroundColor = '';
if (e.dataTransfer.files.length > 0) {
fileInput.files = e.dataTransfer.files;
updateFileDisplay();
}
});
// File input change
fileInput.addEventListener('change', updateFileDisplay);
// Update display when file selected
function updateFileDisplay() {
if (fileInput.files.length > 0) {
const fileName = fileInput.files[0].name;
dropZone.innerHTML = `<i class="fas fa-check-circle text-success fa-2x mb-2 d-block"></i><p class="mb-0 text-success fw-semibold">${fileName}</p>`;
}
}
}
// Setup when modal is first shown
document.addEventListener('DOMContentLoaded', function() {
setupDragDrop();
// Also setup modal reset listener
const modal = document.getElementById('uploadCertModal');
if (modal) {
modal.addEventListener('hidden.bs.modal', function() {
const fileInput = document.getElementById('userfile');
const dropZone = document.getElementById('dropZone');
const uploadContainer = document.getElementById('uploadContainer');
const certUploadBtn = document.getElementById('certUploadBtn');
const messagesDiv = document.getElementById('cert-upload-messages');
if (fileInput) fileInput.value = '';
if (dropZone) {
dropZone.innerHTML = `<i class="fas fa-file-import text-primary fa-2x mb-2 d-block"></i><p class="mb-2 text-muted">Drag and drop your .p12 file here</p><p class="mb-0 text-muted small">or click to browse</p>`;
}
if (uploadContainer) uploadContainer.style.display = 'block';
if (certUploadBtn) certUploadBtn.style.display = 'block';
if (messagesDiv) messagesDiv.innerHTML = '';
});
}
});
</script>
<style>
#dropZone {
cursor: pointer;
transition: all 0.3s ease;
}
#dropZone:hover {
background-color: rgba(13, 110, 253, 0.05) !important;
border-color: #0d6efd !important;
}
#certUploadBtn {
min-width: 140px;
}
.fa-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>