2af04e1db3
- Content model: add url column + is_weblink() for web page content items - Player model: add deployment tracking fields (status, timestamps, message) - Content blueprint: add add_weblink and add_weblink_to_playlist routes; weblinks auto-deleted when removed from playlist - Players blueprint: add SSH deploy mode in add_player; weblink-aware playlist - API blueprint: weblink URL served directly in playlist response; add /api/deploy/test-ssh and /api/deploy/player endpoints - Admin blueprint: add build-player page (clone from Gitea, write base config); replace nginx status card with Caddy status on HTTPS config page - caddy_manager: rewritten to generate proper HTTPS/internal-CA Caddyfiles and reload Caddy via admin API (/load) for live config updates - ssh_deploy, background_tasks, player_build: new utils for SSH deployment - background_tasks: push Flask app context into background thread so DB updates after deployment complete correctly - ssh_deploy: robust install script detection with passwordless sudo injection (uses SSH credentials, cleaned up after install) - Dockerfile: add git, sshpass, openssh-client, rsync - docker-compose: switch nginx to Caddy on ports 80/443; add port 5000 for dev - Templates: add_player deploy mode UI, weblink form in playlist/upload pages, build_player admin page, Caddy status on HTTPS config page - Migrations: add_url_to_content, add_deployment_fields_to_player - app.py: call db.create_all() on startup for schema bootstrap - config.py: add PLAYER_CODE_DIR and PLAYER_REPO_URL settings
613 lines
22 KiB
HTML
613 lines
22 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Upload Media - DigiServer v2{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
.upload-container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
.upload-zone {
|
|
border: 2px dashed #ced4da;
|
|
border-radius: 8px;
|
|
padding: 25px 20px;
|
|
text-align: center;
|
|
background: #f8f9fa;
|
|
transition: all 0.3s;
|
|
cursor: pointer;
|
|
}
|
|
|
|
body.dark-mode .upload-zone {
|
|
background: #1a202c;
|
|
border-color: #4a5568;
|
|
}
|
|
|
|
.upload-zone:hover {
|
|
border-color: #667eea;
|
|
background: #f0f2ff;
|
|
}
|
|
|
|
body.dark-mode .upload-zone:hover {
|
|
border-color: #7c3aed;
|
|
background: #2d3748;
|
|
}
|
|
|
|
.upload-zone.dragover {
|
|
border-color: #667eea;
|
|
background: #e8ebff;
|
|
transform: scale(1.02);
|
|
}
|
|
|
|
body.dark-mode .upload-zone.dragover {
|
|
border-color: #7c3aed;
|
|
background: #2d3748;
|
|
}
|
|
|
|
.file-input-wrapper {
|
|
margin: 20px 0;
|
|
}
|
|
|
|
.file-input-wrapper input[type="file"] {
|
|
display: none;
|
|
}
|
|
|
|
.file-list {
|
|
margin-top: 15px;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.file-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px 10px;
|
|
background: #fff;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 4px;
|
|
margin-bottom: 6px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
body.dark-mode .file-item {
|
|
background: #2d3748;
|
|
border-color: #4a5568;
|
|
}
|
|
|
|
.file-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.file-icon {
|
|
font-size: 20px;
|
|
}
|
|
|
|
.remove-file {
|
|
cursor: pointer;
|
|
color: #dc3545;
|
|
font-weight: bold;
|
|
padding: 5px 10px;
|
|
}
|
|
|
|
.remove-file:hover {
|
|
background: #dc3545;
|
|
color: white;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
font-weight: 600;
|
|
color: #333;
|
|
}
|
|
|
|
body.dark-mode .form-group label {
|
|
color: #e2e8f0;
|
|
}
|
|
|
|
.form-control {
|
|
width: 100%;
|
|
padding: 12px;
|
|
border: 1px solid #ced4da;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
body.dark-mode .form-control {
|
|
background: #1a202c;
|
|
border-color: #4a5568;
|
|
color: #e2e8f0;
|
|
}
|
|
|
|
.form-control:focus {
|
|
outline: none;
|
|
border-color: #667eea;
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
}
|
|
|
|
body.dark-mode .form-control:focus {
|
|
border-color: #7c3aed;
|
|
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
|
|
}
|
|
|
|
.btn-upload {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
border: none;
|
|
padding: 15px 40px;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
width: 100%;
|
|
}
|
|
|
|
.btn-upload:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
|
}
|
|
|
|
.btn-upload:disabled {
|
|
background: #6c757d;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
}
|
|
|
|
/* Dark mode text colors */
|
|
body.dark-mode h1,
|
|
body.dark-mode h2,
|
|
body.dark-mode h3 {
|
|
color: #e2e8f0;
|
|
}
|
|
|
|
body.dark-mode p,
|
|
body.dark-mode small {
|
|
color: #a0aec0;
|
|
}
|
|
|
|
body.dark-mode .file-info > div > div {
|
|
color: #e2e8f0;
|
|
}
|
|
|
|
body.dark-mode .file-info > div > div:last-child {
|
|
color: #718096;
|
|
}
|
|
|
|
.playlist-selector {
|
|
background: #f8f9fa;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
margin-bottom: 30px;
|
|
border: 2px solid #dee2e6;
|
|
}
|
|
|
|
body.dark-mode .playlist-selector {
|
|
background: #1a202c;
|
|
border-color: #4a5568;
|
|
}
|
|
|
|
.playlist-selector.selected {
|
|
border-color: #667eea;
|
|
background: #f0f2ff;
|
|
}
|
|
|
|
body.dark-mode .playlist-selector.selected {
|
|
border-color: #7c3aed;
|
|
background: #2d3748;
|
|
}
|
|
</style>
|
|
|
|
<div class="upload-container">
|
|
<div style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
|
|
<h1 style="display: flex; align-items: center; gap: 0.5rem; font-size: 24px; margin: 0;">
|
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 28px; height: 28px;">
|
|
Upload Media
|
|
</h1>
|
|
<a href="{{ url_for('content.content_list') }}" class="btn" style="display: flex; align-items: center; gap: 0.5rem; padding: 8px 16px;">
|
|
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
|
|
Back to Playlists
|
|
</a>
|
|
</div>
|
|
|
|
<form id="upload-form" method="POST" action="{{ url_for('content.upload_media') }}" enctype="multipart/form-data">
|
|
|
|
<!-- Compact Two-Column Layout -->
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
|
|
|
<!-- Left Column: Upload Zone -->
|
|
<div class="card">
|
|
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
|
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 20px; height: 20px;">
|
|
Select Files
|
|
</h2>
|
|
|
|
<div class="upload-zone" id="upload-zone">
|
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 48px; height: 48px; opacity: 0.3; margin-bottom: 10px;">
|
|
<h3 style="margin-bottom: 8px; font-size: 16px;">Drag & Drop</h3>
|
|
<p style="color: #6c757d; margin: 8px 0; font-size: 13px;">or</p>
|
|
<div class="file-input-wrapper">
|
|
<label for="file-input" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem; padding: 8px 16px; font-size: 14px;">
|
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
|
|
Browse
|
|
</label>
|
|
<input type="file" id="file-input" name="files" multiple
|
|
accept="image/*,video/*,.pdf,.ppt,.pptx">
|
|
</div>
|
|
<p style="font-size: 11px; color: #999; margin-top: 12px; line-height: 1.4;">
|
|
<strong>Supported:</strong> JPG, PNG, GIF, MP4, AVI, MOV, PDF, PPT
|
|
</p>
|
|
</div>
|
|
|
|
<div id="file-list" class="file-list"></div>
|
|
</div>
|
|
|
|
<!-- Right Column: Settings -->
|
|
<div class="card">
|
|
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
|
|
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px;">
|
|
Upload Settings
|
|
</h2>
|
|
|
|
<div class="form-group">
|
|
<label for="playlist_id">Target Playlist (Optional)</label>
|
|
<select name="playlist_id" id="playlist_id" class="form-control">
|
|
<option value="">-- Media Library Only --</option>
|
|
{% for playlist in playlists %}
|
|
<option value="{{ playlist.id }}">
|
|
{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
|
|
💡 Add to playlists later if needed
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="content_type">Media Type</label>
|
|
<select name="content_type" id="content_type" class="form-control">
|
|
<option value="image">Image</option>
|
|
<option value="video">Video</option>
|
|
<option value="pdf">PDF</option>
|
|
<option value="pptx">PPTX</option>
|
|
</select>
|
|
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
|
|
Auto-detected from file extension
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="duration">Default Duration (seconds)</label>
|
|
<input type="number" name="duration" id="duration" class="form-control"
|
|
value="10" min="1" max="300">
|
|
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
|
|
Display time for images and PDFs
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label style="display: flex; align-items: center; cursor: pointer; user-select: none;">
|
|
<input type="checkbox" name="edit_on_player_enabled" id="edit_on_player_enabled"
|
|
value="1" style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
|
|
<span>Allow editing on player (PDF, Images, PPTX)</span>
|
|
</label>
|
|
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
|
|
✏️ Enable local editing of this media on the player device
|
|
</small>
|
|
</div>
|
|
|
|
<!-- Upload Button -->
|
|
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 20px; padding: 12px 30px;">
|
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
|
Upload Files
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- ── Web Link Card ─────────────────────────────────────────────────────── -->
|
|
<div class="card" style="margin-top: 20px;">
|
|
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
|
|
🌐 Add Web Page Link
|
|
</h2>
|
|
<p style="color: #6c757d; font-size: 13px; margin-bottom: 16px;">
|
|
Add a website URL to display on the player (e.g. a dashboard, live feed, or any public web page).
|
|
</p>
|
|
|
|
<form id="weblink-form" method="POST" action="{{ request.script_root }}/content/add-weblink">
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
|
|
|
<!-- URL -->
|
|
<div class="form-group" style="grid-column: 1 / -1;">
|
|
<label for="wl-url">Web Page URL <span style="color:#e53e3e;">*</span></label>
|
|
<input type="url" id="wl-url" name="url" class="form-control"
|
|
placeholder="https://example.com" required>
|
|
<small style="color:#6c757d; font-size:11px;">Must start with http:// or https://</small>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div class="form-group">
|
|
<label for="wl-description">Label / Description</label>
|
|
<input type="text" id="wl-description" name="description" class="form-control"
|
|
placeholder="e.g. Live Dashboard">
|
|
<small style="color:#6c757d; font-size:11px;">Optional — shown in the media library</small>
|
|
</div>
|
|
|
|
<!-- Duration -->
|
|
<div class="form-group">
|
|
<label for="wl-duration">Display Duration (seconds)</label>
|
|
<input type="number" id="wl-duration" name="duration" class="form-control"
|
|
value="30" min="5" max="3600">
|
|
<small style="color:#6c757d; font-size:11px;">How long to show the page per loop</small>
|
|
</div>
|
|
|
|
<!-- Playlist -->
|
|
<div class="form-group" style="grid-column: 1 / -1;">
|
|
<label for="wl-playlist">Add to Playlist (Optional)</label>
|
|
<select id="wl-playlist" name="playlist_id" class="form-control">
|
|
<option value="">-- Media Library Only --</option>
|
|
{% for playlist in playlists %}
|
|
<option value="{{ playlist.id }}">
|
|
{{ playlist.name }} ({{ playlist.orientation }}) — {{ playlist.content_count }} items
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:flex; align-items:center; gap:12px; margin-top:8px;">
|
|
<button type="submit" class="btn-upload" id="wl-submit-btn"
|
|
style="display:inline-flex; align-items:center; gap:0.5rem; padding:10px 24px;">
|
|
🌐 Add Web Link
|
|
</button>
|
|
<span id="wl-status" style="font-size:13px; display:none;"></span>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<script>
|
|
const uploadZone = document.getElementById('upload-zone');
|
|
const fileInput = document.getElementById('file-input');
|
|
const fileList = document.getElementById('file-list');
|
|
const uploadBtn = document.getElementById('upload-btn');
|
|
const uploadForm = document.getElementById('upload-form');
|
|
|
|
let selectedFiles = [];
|
|
|
|
// Prevent default drag behaviors
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
uploadZone.addEventListener(eventName, preventDefaults, false);
|
|
document.body.addEventListener(eventName, preventDefaults, false);
|
|
});
|
|
|
|
function preventDefaults(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
|
|
// Highlight drop zone when item is dragged over it
|
|
['dragenter', 'dragover'].forEach(eventName => {
|
|
uploadZone.addEventListener(eventName, () => {
|
|
uploadZone.classList.add('dragover');
|
|
});
|
|
});
|
|
|
|
['dragleave', 'drop'].forEach(eventName => {
|
|
uploadZone.addEventListener(eventName, () => {
|
|
uploadZone.classList.remove('dragover');
|
|
});
|
|
});
|
|
|
|
// Handle dropped files
|
|
uploadZone.addEventListener('drop', (e) => {
|
|
const dt = e.dataTransfer;
|
|
const files = dt.files;
|
|
handleFiles(files);
|
|
});
|
|
|
|
// Handle file input
|
|
fileInput.addEventListener('change', (e) => {
|
|
console.log('File input changed, files:', e.target.files.length);
|
|
if (e.target.files.length > 0) {
|
|
handleFiles(e.target.files);
|
|
}
|
|
});
|
|
|
|
// Click upload zone to trigger file input (but not if clicking on the label)
|
|
uploadZone.addEventListener('click', (e) => {
|
|
// Don't trigger if clicking on the label or button
|
|
if (e.target.tagName === 'LABEL' || e.target.closest('label') || e.target.tagName === 'INPUT') {
|
|
return;
|
|
}
|
|
fileInput.click();
|
|
});
|
|
|
|
function handleFiles(files) {
|
|
console.log('handleFiles called with', files.length, 'file(s)');
|
|
selectedFiles = Array.from(files);
|
|
displayFiles();
|
|
uploadBtn.disabled = selectedFiles.length === 0;
|
|
|
|
// Auto-detect media type and duration from first file
|
|
if (selectedFiles.length > 0) {
|
|
console.log('Auto-detecting for first file:', selectedFiles[0].name);
|
|
autoDetectMediaType(selectedFiles[0]);
|
|
autoDetectDuration(selectedFiles[0]);
|
|
}
|
|
}
|
|
|
|
function autoDetectMediaType(file) {
|
|
const ext = file.name.split('.').pop().toLowerCase();
|
|
const contentTypeSelect = document.getElementById('content_type');
|
|
|
|
console.log('Auto-detecting media type for:', file.name, 'Extension:', ext);
|
|
|
|
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
|
|
contentTypeSelect.value = 'image';
|
|
console.log('Set type to: image');
|
|
} else if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
|
|
contentTypeSelect.value = 'video';
|
|
console.log('Set type to: video');
|
|
} else if (ext === 'pdf') {
|
|
contentTypeSelect.value = 'pdf';
|
|
console.log('Set type to: pdf');
|
|
} else if (['ppt', 'pptx'].includes(ext)) {
|
|
contentTypeSelect.value = 'pptx';
|
|
console.log('Set type to: pptx');
|
|
}
|
|
}
|
|
|
|
function autoDetectDuration(file) {
|
|
const ext = file.name.split('.').pop().toLowerCase();
|
|
const durationInput = document.getElementById('duration');
|
|
|
|
console.log('Auto-detecting duration for:', file.name, 'Extension:', ext);
|
|
|
|
// For videos, try to get actual duration
|
|
if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
|
|
console.log('Processing as video...');
|
|
const video = document.createElement('video');
|
|
video.preload = 'metadata';
|
|
|
|
video.onloadedmetadata = function() {
|
|
window.URL.revokeObjectURL(video.src);
|
|
const duration = Math.ceil(video.duration);
|
|
console.log('Video duration detected:', duration, 'seconds');
|
|
if (duration && duration > 0) {
|
|
durationInput.value = duration;
|
|
}
|
|
};
|
|
|
|
video.onerror = function() {
|
|
console.log('Video loading error, using default 30s');
|
|
window.URL.revokeObjectURL(video.src);
|
|
durationInput.value = 30; // Default for videos if can't read duration
|
|
};
|
|
|
|
video.src = URL.createObjectURL(file);
|
|
} else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
|
|
// Images: default 10 seconds
|
|
console.log('Setting image duration: 10s');
|
|
durationInput.value = 10;
|
|
} else if (ext === 'pdf') {
|
|
// PDFs: default 15 seconds per page (estimate)
|
|
console.log('Setting PDF duration: 15s');
|
|
durationInput.value = 15;
|
|
} else if (['ppt', 'pptx'].includes(ext)) {
|
|
// Presentations: default 20 seconds per slide (estimate)
|
|
console.log('Setting presentation duration: 20s');
|
|
durationInput.value = 20;
|
|
}
|
|
}
|
|
|
|
function displayFiles() {
|
|
fileList.innerHTML = '';
|
|
|
|
if (selectedFiles.length === 0) {
|
|
return;
|
|
}
|
|
|
|
selectedFiles.forEach((file, index) => {
|
|
const fileItem = document.createElement('div');
|
|
fileItem.className = 'file-item';
|
|
|
|
const ext = file.name.split('.').pop().toLowerCase();
|
|
let icon = '📁';
|
|
if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(ext)) icon = '📷';
|
|
else if (['mp4', 'avi', 'mov', 'mkv', 'webm'].includes(ext)) icon = '🎥';
|
|
else if (ext === 'pdf') icon = '📄';
|
|
else if (['ppt', 'pptx'].includes(ext)) icon = '📊';
|
|
|
|
const sizeInMB = (file.size / (1024 * 1024)).toFixed(2);
|
|
|
|
fileItem.innerHTML = `
|
|
<div class="file-info">
|
|
<span class="file-icon">${icon}</span>
|
|
<div>
|
|
<div style="font-weight: 600;">${file.name}</div>
|
|
<div style="font-size: 12px; color: #6c757d;">${sizeInMB} MB</div>
|
|
</div>
|
|
</div>
|
|
<span class="remove-file" onclick="removeFile(${index})">✕</span>
|
|
`;
|
|
|
|
fileList.appendChild(fileItem);
|
|
});
|
|
}
|
|
|
|
function removeFile(index) {
|
|
selectedFiles.splice(index, 1);
|
|
displayFiles();
|
|
uploadBtn.disabled = selectedFiles.length === 0;
|
|
|
|
// Update file input
|
|
const dt = new DataTransfer();
|
|
selectedFiles.forEach(file => dt.items.add(file));
|
|
fileInput.files = dt.files;
|
|
}
|
|
|
|
// Form submission
|
|
uploadForm.addEventListener('submit', (e) => {
|
|
if (selectedFiles.length === 0) {
|
|
e.preventDefault();
|
|
alert('Please select files to upload');
|
|
return;
|
|
}
|
|
|
|
uploadBtn.disabled = true;
|
|
uploadBtn.innerHTML = '⏳ Uploading...';
|
|
});
|
|
|
|
// ── Web Link form — AJAX submit ────────────────────────────────────────
|
|
const wlForm = document.getElementById('weblink-form');
|
|
const wlBtn = document.getElementById('wl-submit-btn');
|
|
const wlStatus = document.getElementById('wl-status');
|
|
|
|
wlForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
wlBtn.disabled = true;
|
|
wlBtn.textContent = '⏳ Adding…';
|
|
wlStatus.style.display = 'none';
|
|
|
|
try {
|
|
const resp = await fetch(wlForm.action, {
|
|
method: 'POST',
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
|
body: new FormData(wlForm),
|
|
});
|
|
const data = await resp.json();
|
|
|
|
if (data.success) {
|
|
wlStatus.style.color = '#38a169';
|
|
wlStatus.textContent = '✓ ' + data.message;
|
|
wlForm.reset();
|
|
document.getElementById('wl-duration').value = 30;
|
|
} else {
|
|
wlStatus.style.color = '#e53e3e';
|
|
wlStatus.textContent = '✗ ' + (data.error || 'Failed to add web link.');
|
|
}
|
|
} catch (err) {
|
|
wlStatus.style.color = '#e53e3e';
|
|
wlStatus.textContent = '✗ Network error — please try again.';
|
|
}
|
|
|
|
wlStatus.style.display = 'inline';
|
|
wlBtn.disabled = false;
|
|
wlBtn.innerHTML = '🌐 Add Web Link';
|
|
});
|
|
</script>
|
|
|
|
{% endblock %}
|