pahvalentines/frontend/admin.html
2026-02-11 18:36:39 +05:30

433 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.datatables.net/2.3.6/css/dataTables.dataTables.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/responsive/3.0.7/css/responsive.dataTables.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/buttons/3.0.2/css/buttons.dataTables.min.css">
<link href="https://cdn.datatables.net/columncontrol/1.2.0/css/columnControl.dataTables.min.css" rel="stylesheet">
<style>
body { background-color: #f8f9fa; }
.user-photo {
width: 45px;
height: 45px;
object-fit: cover;
border-radius: 10%;
border: 1px solid #ddd;
}
/* Style adjustments for DataTables 2.0 + Bootstrap */
.dt-container { margin-top: 20px; padding: 5px; }
/* Define the 9:16 vertical ratio */
/* Calculation: (16 / 9) * 100 = 177.77% */
.ratio-9x16 {
--bs-aspect-ratio: 177.7777777778%;
}
</style>
</head>
<body>
<nav class="navbar navbar-dark bg-dark shadow-sm mb-4">
<div class="container">
<span class="navbar-brand">Admin Dashboard</span>
</div>
</nav>
<div class="container mb-5">
<div class="card shadow-sm">
<div class="card-body">
<div class="row g-3 mb-3">
<!-- Column 1: Total Records -->
<div class="col-md-3">
<div class="alert alert-light mb-0 h-100 d-flex align-items-center">
<span>
Total Records:&nbsp;
<span id="total-records-count" class="badge bg-dark rounded-pill">0</span>
</span>
</div>
</div>
<!-- Column 2: Pending Submissions -->
<div class="col-md-3">
<div class="alert alert-light mb-0 h-100 d-flex align-items-center">
<span>
Sonauto Pending:&nbsp;
<span id="pending-submissions" class="badge bg-warning rounded-pill">Loading...</span>
</span>
</div>
</div>
<!-- Column 3: Processing Submissions -->
<div class="col-md-3" style="display: none;">
<div class="alert alert-light mb-0 h-100 d-flex align-items-center">
<span>
Video Queue:&nbsp;
<span id="processing-submissions" class="badge bg-info rounded-pill">Loading...</span>
</span>
</div>
</div>
<!-- Column 4: AI Credits -->
<div class="col-md-3">
<div class="alert alert-light mb-0 h-100 d-flex align-items-center">
<span>
SonautoAI Credits Remaining:&nbsp;
<span id="credits-remaining" class="badge bg-primary rounded-pill">Loading...</span>
</span>
</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm dt-container">
<div class="card-dt">
<table id="usersTable" class="table table-striped w-100">
<thead>
<tr>
<th>Actions</th>
<th>Session</th>
<th>Created</th>
<th>Cookie ID</th>
<th>Owner Name</th>
<th>Pet Name</th>
<th>Photo</th>
<th>Pet Type</th>
<th>Vibe</th>
<th>Retries</th>
<th>Sent to LLM</th>
<th>Task ID</th>
<th>Recv from LLM</th>
<th>LLM Response</th>
<th>Full Response</th>
<th>Song Path</th>
<th>LLM Status</th>
<th>Lyrics</th>
<th>Vid Start</th>
<th>Vid End</th>
<th>Video Path</th>
<th>Entry Status</th>
</tr>
</thead>
<tbody></tbody> <!-- Empty: DataTables will fill this -->
</table>
</div>
</div>
</div>
<!-- 1. Use modal-xl for desktop real estate -->
<div class="modal fade" id="photoModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Record Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row">
<!-- Left Column: Media (Fixed Width for 9:16) -->
<div class="col-md-4 border-end">
<h6>Original Photo</h6>
<img src="" id="modalImage" class="img-thumbnail mb-3"
style="width: 150px; height: 150px; object-fit: cover;" loading="lazy">
<h6 class="mt-3">Generated Assets</h6>
<div id="modalMediaContainer" class="d-flex flex-column gap-3">
<!-- Video and Audio injected here -->
</div>
</div>
<!-- Right Column: Full Data Table -->
<div class="col-md-8">
<h6>Full Entry Metadata</h6>
<div class="table-responsive">
<table class="table table-sm table-hover border">
<thead class="table-light">
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody id="modalDetailsTableBody">
<!-- Dynamic content -->
</tbody>
</table>
</div>
<h6 class="mt-4">Lyrics</h6>
<pre id="modalLyrics" class="bg-dark text-info p-3 rounded"
style="white-space: pre-wrap; font-size: 0.85rem; max-height: 300px; overflow-y: auto;">
</pre>
</div>
</div>
</div>
</div>
</div>
</div>
<script defer src="./assets/vendor/alpine.min.js"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/2.3.6/js/dataTables.min.js"></script>
<script src="https://cdn.datatables.net/responsive/3.0.7/js/dataTables.responsive.min.js"></script>
<script src="https://cdn.datatables.net/buttons/3.0.2/js/dataTables.buttons.min.js"></script>
<script src="https://cdn.datatables.net/buttons/3.0.2/js/buttons.colVis.min.js"></script>
<script src="https://cdn.datatables.net/columncontrol/1.2.0/js/dataTables.columnControl.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@3.7.2/build/global/luxon.min.js"></script>
<script>
$(document).ready(function () {
const API_BASE_URL = '';
const API_ENDPOINT = `/back/api/admin/data`;
const MEDIA_BASE_URL = 'https://storage.googleapis.com/vday2026/';
// Initialize the Modal object once so we can reuse it
const detailModalElement = document.getElementById('photoModal');
const detailModal = new bootstrap.Modal(detailModalElement);
// Helper to convert container paths to web URLs
function fixStoragePath(path) {
if (!path) return '';
// let relative_path = path.replace(/^\/app\/storage\//, '/storage/');
const isRelative = path.startsWith('uploads/') || path.startsWith('audio/') || path.startsWith('video/');
return isRelative ? MEDIA_BASE_URL + path : path;
}
// Fetch and update system status metrics
function loadCredits() {
const STATUS_ENDPOINT = `/back/api/admin/queue-status`;
fetch(STATUS_ENDPOINT)
.then(response => response.json())
.then(data => {
// Update all three metrics from the API
$('#credits-remaining').text((data.sonauto_credits || 0).toLocaleString());
$('#pending-submissions').text((data.pending_submissions || 0).toLocaleString());
$('#processing-submissions').text((data.processing_submissions || 0).toLocaleString());
})
.catch(err => {
console.error('Failed to load credits:', err);
$('#credits-remaining').text('Error');
$('#pending-submissions').text('Error');
$('#processing-submissions').text('Error');
});
}
loadCredits();
// Poll every 2 minutes
setInterval(loadCredits, (2*60*1000));
// Initialize DataTable
const table = $('#usersTable').DataTable({
serverSide: true,
processing: true,
pageLength: 50,
order: {
name: 'created_at',
dir: 'desc'
},
deferRender: true,
responsive: {
details: {
type: 'column',
target: 'tr' // Clicking the row expands it
}
},
// Define which columns are visible by default
columnDefs: [
{ responsivePriority: 1, targets: [3, 4, 12, 5] }, // Owner, Pet, Status, Photo
{ responsivePriority: 2, targets: [1, 20] }, // CreatedAt, EntryStatus
{ targets: [2, 6, 8, 9, 10, 11, 14, 17, 18], visible: false } // Hide technical IDs by default
],
ajax: {
url: API_ENDPOINT,
dataSrc: function (json) {
// Your FastAPI endpoint returns AdminResponse directly
// Structure: { draw, recordsTotal, recordsFiltered, data }
// console.log('DEBUG: API Response:', json);
// Update the total records badge at the top
$('#total-records-count').text((json.recordsTotal || 0).toLocaleString());
// DataTables expects these fields at root level (already there!)
// No need to move anything - just return the data array
return json.data || [];
}
},
layout: {
topStart: {
buttons: [
{
extend: 'colvis',
text: 'Select Columns',
className: 'btn btn-sm btn-outline-secondary'
}
]
},
topEnd: 'search',
bottomStart: 'info',
bottomEnd: 'paging'
},
columns: [
{
data: null,
title: 'Actions',
name: 'actions',
defaultContent: '',
orderable: false,
searchable: false,
render: function (data, type, row) {
return `<button class="btn btn-sm btn-primary view-details-btn" type="button">
View
</button>`;
}
},
{ data: 'session_id', title: 'Session', name: 'session_id', defaultContent: 'N/A', visible: false },
{
data: 'created_at',
title: 'Created',
ordering: true,
name: 'created_at',
defaultContent: 'N/A',
visible: true,
render: (data) => {
if (!data) return 'N/A';
return luxon.DateTime.fromISO(data).toFormat('yyyy-MM-dd HH:mm:ss') + ' UTC';
}
},
{ data: 'cookie_id', title: 'Cookie ID', name: 'cookie_id', defaultContent: 'N/A', visible: false },
{ data: 'owner_name', title: 'Owner', name: 'owner_name', defaultContent: 'N/A' },
{ data: 'pet_name', title: 'Pet Name', name: 'pet_name', defaultContent: 'N/A' },
{
data: 'photo_path',
title: 'Photo',
name: 'photo_path',
defaultContent: '',
visible: true,
render: (data) => {
const imgPath = fixStoragePath(data) || "https://placehold.co/500x500/fbba0e/e11d48/jpg?text=No\nImage";
return `<img src="${imgPath}" class="user-photo img-preview" style="cursor:pointer">`;
}
},
{ data: 'pet_type', name: 'pet_type', title: 'Type', defaultContent: 'N/A' },
{ data: 'music_vibe', name: 'music_vibe', title: 'Vibe', defaultContent: 'N/A' },
{ data: 'retry_count', name: 'retry_count', title: 'Retries', defaultContent: 'N/A', visible: false },
{ data: 'sent_to_LLM', name: 'sent_to_LLM', title: 'Sent to LLM', defaultContent: 'N/A' },
{ data: 'LLM_task_id', name: 'LLM_task_id', title: 'Task ID', defaultContent: 'N/A', visible: false },
{ data: 'received_from_LLM', name: 'received_from_LLM', title: 'LLM Recv', defaultContent: 'N/A', visible: false },
{
data: 'LLM_response',
title: 'LLM Response',
name: 'LLM_response',
defaultContent: 'N/A',
visible: false,
render: (data) => data ? (data.substring(0, 30) + '...') : 'Pending'
},
{ data: 'LLM_full_response', name: 'LLM_full_response', defaultContent: 'N/A', visible: false },
{ data: 'generated_song_path', name: 'generated_song_path', title: 'Song', defaultContent: '', render: d => d ? 'Yes' : 'No' },
{ data: 'LLM_status', name: 'LLM_status', title: 'LLM Status', defaultContent: 'N/A', visible: true },
{ data: 'lyrics', name: 'lyrics', title: 'Lyrics', defaultContent: '', visible: false },
{ data: 'video_creation_start', name: 'video_creation_start', title: 'Vid Start', defaultContent: '', visible: false },
{ data: 'video_creation_end', name: 'video_creation_end', title: 'Vid End', defaultContent: '', visible: false },
{ data: 'generated_video_path', name: 'generated_video_path', title: 'VideoGen', defaultContent: '', render: d => d ? 'Yes' : 'No', visible: false },
{
data: 'entry_status',
title: 'Status',
name: 'entry_status',
defaultContent: '',
visible: true ,
render: (data) => {
return `<span>${data}</span>`;
}
}
]
});
// Use event delegation (so it works even after searching/paging)
$('#usersTable').on('click', '.view-details-btn, .img-preview', function () {
// 1. Get the data for the specific row clicked
// We use .closest('tr') to find the table row containing the clicked image
const rowData = table.row($(this).closest('tr')).data();
// 2. Populate the Modal fields
$('.modal-title').text(`Session: ${rowData.session_id}`);
$('#modalImage').attr('src', fixStoragePath(rowData.photo_path) || "https://placehold.co/500x500/fbba0e/e11d48/jpg?text=No\nImage");
// 3. Dynamic Table Generation for all 20+ columns
const tableBody = $('#modalDetailsTableBody');
tableBody.empty();
// List of keys we want to display or skip
Object.entries(rowData).forEach(([key, value]) => {
// Skip keys that are handled visually (media/lyrics)
if (['photo_path', 'generated_video_path', 'generated_song_path', 'lyrics'].includes(key))
return;
let displayValue = value ?? '<span class="text-muted italic">null</span>';
// Surgical Link Detection: Only link if it has a known file extension
const isFile = typeof value === 'string' && /\.(jpg|jpeg|png|mp3|mp4|wav)$/i.test(value);
if (isFile) {
const fullUrl = fixStoragePath(value);
displayValue = `<a href="${fullUrl}" target="_blank" class="text-decoration-none text-truncate d-inline-block" style="max-width: 300px;">${value} 🔗</a>`;
}
tableBody.append(`
<tr>
<td class="fw-bold text-muted" style="width: 30%">${key.replace(/_/g, ' ')}</td>
<td class="text-break">${displayValue}</td>
</tr>
`);
});
$('#modalLyrics').text(rowData.lyrics || 'No lyrics generated.');
// 4. Handle Media (Song or Video)
const mediaContainer = $('#modalMediaContainer');
// Define the URLs before using them
const audioUrl = fixStoragePath(rowData.generated_song_path);
const videoUrl = fixStoragePath(rowData.generated_video_path);
// 1. Clear previous content
mediaContainer.empty();
// 2. Check for Audio
if (rowData.generated_song_path) {
console.log('Adding Audio Player...');
mediaContainer.append(`
<div class="audio-section">
<label class="small text-muted mb-1">Audio Track:</label>
<code class="d-block mb-2 text-break p-2 bg-light border rounded" style="font-size: 0.7rem;">${audioUrl}</code>
<audio controls preload="metadata" class="w-100" src="${audioUrl}"></audio>
</div>
`);
}
// 3. Check for Video
if (rowData.generated_video_path) {
console.log('Adding Video Player...');
mediaContainer.append(`
<div class="video-section mb-3">
<label class="small text-muted mb-1">Video (9:16):</label>
<code class="d-block mb-2 text-break p-2 bg-light border rounded" style="font-size: 0.7rem;">${videoUrl}</code>
<div class="ratio ratio-9x16 bg-black rounded shadow-sm overflow-hidden mx-auto" style="max-width: 220px;">
<video controls preload="metadata" class="w-100 h-100" src="${videoUrl}">
Your browser does not support the video tag.
</video>
</div>
</div>
`);
}
if (!rowData.generated_song_path && !rowData.generated_video_path) {
mediaContainer.html('<div class="d-flex align-items-center justify-content-center h-100 text-white-50">No Media</div>');
}
// 5. Show the Modal
detailModal.show();
});
});
</script>
</body>
</html>