Add company dashboard improvements and upload/auth features

This commit is contained in:
2026-01-23 20:21:11 +01:00
parent 1394ef6f67
commit ea3d0164f2
14 changed files with 1004 additions and 112 deletions

View File

@@ -65,6 +65,14 @@
<div class="d-flex justify-content-between align-items-center">
<h1 class="h3">Playlist: {{ playlist.name }}</h1>
<div class="d-flex gap-2">
<button
class="btn btn-outline-primary btn-sm"
type="button"
data-bs-toggle="modal"
data-bs-target="#renamePlaylistModal"
>
Rename
</button>
<form method="post" action="{{ url_for('company.delete_playlist', playlist_id=playlist.id) }}" onsubmit="return confirm('Delete playlist? This will remove all items and unassign it from displays.');">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete playlist</button>
</form>
@@ -72,6 +80,38 @@
</div>
</div>
{# Rename Playlist Modal #}
<div class="modal fade" id="renamePlaylistModal" tabindex="-1" aria-labelledby="renamePlaylistModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="renamePlaylistModalLabel">Rename playlist</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="{{ url_for('company.update_playlist', playlist_id=playlist.id) }}">
<div class="modal-body">
<label class="form-label" for="playlist-name">Name</label>
<input
id="playlist-name"
class="form-control"
name="name"
value="{{ playlist.name }}"
placeholder="Playlist name"
maxlength="120"
required
autofocus
/>
<div class="text-muted small mt-2">Rename only changes the playlist title; items and assignments stay the same.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-ink" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-brand">Save</button>
</div>
</form>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-4">
<div>
<h2 class="h5 mb-0">Items</h2>
@@ -164,6 +204,7 @@
<form id="add-item-form" method="post" action="{{ url_for('company.add_playlist_item', playlist_id=playlist.id) }}" enctype="multipart/form-data">
<input type="hidden" name="response" value="json" />
<input type="hidden" name="item_type" id="item_type" value="image" />
<input type="hidden" name="crop_mode" id="crop_mode" value="16:9" />
<div class="mb-2">
<label class="form-label">Title (optional)</label>
@@ -192,6 +233,21 @@
</div>
</div>
<div class="mb-3" id="crop-mode-group">
<label class="form-label">Image crop</label>
<div class="btn-group w-100" role="group" aria-label="Crop mode">
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-16-9" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="crop-16-9">16:9 (landscape)</label>
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-9-16" autocomplete="off">
<label class="btn btn-outline-primary" for="crop-9-16">9:16 (portrait)</label>
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-none" autocomplete="off">
<label class="btn btn-outline-primary" for="crop-none">No crop</label>
</div>
<div class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
</div>
<style>
.dropzone {
border: 2px dashed #6c757d;
@@ -289,7 +345,7 @@
</div>
<div id="step-crop" class="step">
<div class="text-muted small mb-2">Crop to <strong>16:9</strong> (recommended for display screens).</div>
<div class="text-muted small mb-2" id="crop-step-hint">Crop to <strong>16:9</strong> (recommended for display screens).</div>
<div style="width: 100%; background: #111; border-radius: .25rem; overflow: hidden;">
<img id="image-crop-target" alt="Crop" style="max-width: 100%; display: block;" />
</div>
@@ -324,8 +380,11 @@
if (!form) return;
const typeHidden = document.getElementById('item_type');
const cropModeHidden = document.getElementById('crop_mode');
const submitBtn = document.getElementById('add-item-submit');
const durationGroup = document.getElementById('duration-group');
const cropModeGroup = document.getElementById('crop-mode-group');
const cropHint = document.getElementById('crop-step-hint');
const sectionImage = document.getElementById('section-image');
const sectionWebpage = document.getElementById('section-webpage');
@@ -357,6 +416,7 @@
sectionVideo.classList.toggle('d-none', t !== 'video');
// duration applies to image/webpage/youtube. Video plays until ended.
durationGroup.classList.toggle('d-none', t === 'video');
cropModeGroup?.classList.toggle('d-none', t !== 'image');
submitBtn.disabled = false;
submitBtn.title = '';
@@ -373,6 +433,26 @@
}
}
function currentCropMode() {
// crop_mode_choice is only UI; we submit hidden crop_mode for server fallback
return (cropModeHidden?.value || '16:9').toLowerCase();
}
function updateCropHint() {
const cm = currentCropMode();
if (!cropHint) return;
if (cm === 'none') {
cropHint.innerHTML = 'No crop selected. The image will be resized/compressed, keeping its original aspect ratio.';
if (cropResetBtn) cropResetBtn.disabled = true;
} else if (cm === '9:16') {
cropHint.innerHTML = 'Crop to <strong>9:16</strong> (portrait).';
if (cropResetBtn) cropResetBtn.disabled = false;
} else {
cropHint.innerHTML = 'Crop to <strong>16:9</strong> (landscape, recommended for display screens).';
if (cropResetBtn) cropResetBtn.disabled = false;
}
}
document.getElementById('type-image')?.addEventListener('change', () => setType('image'));
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage'));
document.getElementById('type-youtube')?.addEventListener('change', () => setType('youtube'));
@@ -437,13 +517,19 @@
return;
}
cropper = new window.Cropper(cropImg, {
aspectRatio: 16 / 9,
viewMode: 1,
autoCropArea: 1,
responsive: true,
background: false,
});
// Create cropper only when cropping is enabled
const cm = currentCropMode();
if (cm !== 'none') {
cropper = new window.Cropper(cropImg, {
aspectRatio: (cm === '9:16') ? (9 / 16) : (16 / 9),
viewMode: 1,
autoCropArea: 1,
responsive: true,
background: false,
});
}
updateCropHint();
// Enable Add button now that cropper exists
if (typeHidden.value === 'image') submitBtn.disabled = false;
@@ -486,26 +572,33 @@
// If image, replace file with cropped version before sending.
if (typeHidden.value === 'image') {
if (!cropper) {
cropStatus.textContent = 'Please select an image first.';
submitBtn.disabled = false;
return;
const cm = currentCropMode();
// If no crop is selected, just upload the original file.
if (cm !== 'none') {
if (!cropper) {
cropStatus.textContent = 'Please select an image first.';
submitBtn.disabled = false;
return;
}
cropStatus.textContent = 'Preparing cropped image…';
const isPortrait = cm === '9:16';
const canvas = cropper.getCroppedCanvas({
width: isPortrait ? 720 : 1280,
height: isPortrait ? 1280 : 720,
imageSmoothingQuality: 'high',
});
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) {
cropStatus.textContent = 'Failed to crop image.';
submitBtn.disabled = false;
return;
}
const croppedFile = new File([blob], 'cropped.png', { type: 'image/png' });
setFileOnInput(fileInput, croppedFile);
cropStatus.textContent = '';
}
cropStatus.textContent = 'Preparing cropped image…';
const canvas = cropper.getCroppedCanvas({
width: 1280,
height: 720,
imageSmoothingQuality: 'high',
});
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) {
cropStatus.textContent = 'Failed to crop image.';
submitBtn.disabled = false;
return;
}
const croppedFile = new File([blob], 'cropped.png', { type: 'image/png' });
setFileOnInput(fileInput, croppedFile);
cropStatus.textContent = '';
}
const fd = new FormData(form);
@@ -553,6 +646,8 @@
form.reset();
typeHidden.value = 'image';
document.getElementById('type-image')?.click();
if (cropModeHidden) cropModeHidden.value = '16:9';
document.getElementById('crop-16-9')?.click();
destroyCropper();
showStep('select');
submitBtn.disabled = true;
@@ -648,6 +743,9 @@
setEnabled(urlInput, t === 'webpage');
setEnabled(youtubeUrlInput, t === 'youtube');
// Crop mode only applies to images
setEnabled(cropModeHidden, t === 'image');
if (t === 'webpage') {
// Keep preview behavior
schedulePreview();
@@ -661,8 +759,10 @@
// Set initial state
setType('image');
if (cropModeHidden) cropModeHidden.value = '16:9';
showStep('select');
syncEnabledInputs();
updateCropHint();
// Modal open
openBtn?.addEventListener('click', () => {
@@ -678,6 +778,43 @@
}
});
// Crop mode selection
function setCropMode(mode) {
if (!cropModeHidden) return;
cropModeHidden.value = mode;
// If cropper exists, update aspect ratio or destroy it
const cm = currentCropMode();
if (typeHidden.value !== 'image') return;
if (!cropImg?.src) {
updateCropHint();
return;
}
// Ensure cropper state matches selection
if (cm === 'none') {
destroyCropper();
// Re-load the selected file into the preview without cropper.
const f = fileInput?.files?.[0];
if (f) loadImageFile(f);
return;
}
if (cropper) {
cropper.setAspectRatio((cm === '9:16') ? (9 / 16) : (16 / 9));
updateCropHint();
return;
}
// No cropper yet (e.g. user changed crop mode after selecting file but before cropper init)
const f = fileInput?.files?.[0];
if (f) loadImageFile(f);
}
document.getElementById('crop-16-9')?.addEventListener('change', () => setCropMode('16:9'));
document.getElementById('crop-9-16')?.addEventListener('change', () => setCropMode('9:16'));
document.getElementById('crop-none')?.addEventListener('change', () => setCropMode('none'));
// Whenever type changes, keep enabled inputs in sync
['type-image','type-webpage','type-youtube','type-video'].forEach((id) => {
document.getElementById(id)?.addEventListener('change', syncEnabledInputs);