You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
454 lines
16 KiB
454 lines
16 KiB
{% extends "portal_base.html" %} |
|
|
|
{% block title %}Device Files - OTB Cloud{% endblock %} |
|
|
|
{% block portal_content %} |
|
<style> |
|
.otb-view-toggle { |
|
display: inline-flex; |
|
gap: 8px; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.otb-view-toggle a { |
|
text-decoration: none; |
|
} |
|
|
|
.otb-gallery-grid { |
|
display: grid; |
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); |
|
gap: 16px; |
|
margin-top: 18px; |
|
} |
|
|
|
.otb-gallery-card { |
|
border: 1px solid rgba(255,255,255,0.10); |
|
border-radius: 16px; |
|
background: rgba(255,255,255,0.03); |
|
overflow: hidden; |
|
display: flex; |
|
flex-direction: column; |
|
min-height: 320px; |
|
} |
|
|
|
.otb-gallery-thumb-wrap { |
|
position: relative; |
|
aspect-ratio: 1 / 1; |
|
background: rgba(255,255,255,0.04); |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
overflow: hidden; |
|
} |
|
|
|
.otb-gallery-thumb { |
|
width: 100%; |
|
height: 100%; |
|
object-fit: cover; |
|
cursor: pointer; |
|
display: block; |
|
} |
|
|
|
.otb-gallery-check { |
|
position: absolute; |
|
top: 10px; |
|
left: 10px; |
|
z-index: 2; |
|
transform: scale(1.2); |
|
} |
|
|
|
.otb-gallery-meta { |
|
padding: 12px; |
|
display: flex; |
|
flex-direction: column; |
|
gap: 8px; |
|
} |
|
|
|
.otb-gallery-name { |
|
font-weight: 700; |
|
word-break: break-word; |
|
line-height: 1.25; |
|
} |
|
|
|
.otb-gallery-sub { |
|
font-size: 0.9rem; |
|
opacity: 0.8; |
|
line-height: 1.35; |
|
} |
|
|
|
.otb-gallery-actions { |
|
display: flex; |
|
gap: 8px; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.otb-gallery-rename { |
|
display: flex; |
|
gap: 8px; |
|
align-items: center; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.otb-gallery-rename input { |
|
min-width: 120px; |
|
flex: 1 1 120px; |
|
padding: 8px 10px; |
|
border-radius: 10px; |
|
border: 1px solid rgba(255,255,255,0.15); |
|
background: rgba(255,255,255,0.06); |
|
color: #fff; |
|
} |
|
|
|
.otb-modal-backdrop { |
|
display: none; |
|
position: fixed; |
|
inset: 0; |
|
background: rgba(0,0,0,0.85); |
|
z-index: 9999; |
|
align-items: center; |
|
justify-content: center; |
|
padding: 20px; |
|
} |
|
|
|
.otb-modal-backdrop.active { |
|
display: flex; |
|
} |
|
|
|
.otb-modal-card { |
|
max-width: min(96vw, 1400px); |
|
max-height: 94vh; |
|
width: 100%; |
|
background: rgba(11,18,37,0.98); |
|
border: 1px solid rgba(255,255,255,0.12); |
|
border-radius: 18px; |
|
overflow: hidden; |
|
box-shadow: 0 18px 50px rgba(0,0,0,0.45); |
|
} |
|
|
|
.otb-modal-header { |
|
display: flex; |
|
align-items: center; |
|
justify-content: space-between; |
|
gap: 10px; |
|
padding: 14px 16px; |
|
border-bottom: 1px solid rgba(255,255,255,0.10); |
|
} |
|
|
|
.otb-modal-title { |
|
font-weight: 700; |
|
word-break: break-word; |
|
} |
|
|
|
.otb-modal-body { |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
background: #000; |
|
max-height: calc(94vh - 64px); |
|
overflow: auto; |
|
} |
|
|
|
.otb-modal-body img { |
|
max-width: 100%; |
|
max-height: calc(94vh - 100px); |
|
object-fit: contain; |
|
display: block; |
|
} |
|
|
|
.otb-list-name-wrap { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 8px; |
|
min-width: 320px; |
|
} |
|
|
|
.otb-list-rename { |
|
display: flex; |
|
gap: 8px; |
|
align-items: center; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.otb-list-rename input { |
|
min-width: 220px; |
|
padding: 8px 10px; |
|
border-radius: 10px; |
|
border: 1px solid rgba(255,255,255,0.15); |
|
background: rgba(255,255,255,0.06); |
|
color: #fff; |
|
} |
|
</style> |
|
|
|
<div class="portal-page-header"> |
|
<div> |
|
<h1 class="portal-page-title">Device Files</h1> |
|
<p class="portal-client-name">{{ user_email }}</p> |
|
<p class="portal-page-subtitle"> |
|
Browsing files for <strong>{{ device.device_name }}</strong> ({{ device.device_type }}). |
|
</p> |
|
</div> |
|
|
|
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;"> |
|
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;"> |
|
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a> |
|
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Zip Workspace</a> |
|
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a> |
|
</div> |
|
|
|
<div class="otb-view-toggle"> |
|
<a class="portal-btn {% if view_mode == 'list' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='list') }}">List View</a> |
|
<a class="portal-btn {% if view_mode == 'gallery' %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view='gallery') }}">Gallery View</a> |
|
</div> |
|
|
|
<div style="text-align:right;font-size:14px;opacity:0.95;"> |
|
<div>File count: <strong>{{ file_count }}</strong></div> |
|
<div>Device path: <strong>{{ device.relative_path }}</strong></div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
{% with messages = get_flashed_messages(with_categories=true) %} |
|
{% if messages %} |
|
<section style="margin-bottom:22px;"> |
|
{% for category, message in messages %} |
|
<div class="service-card" style="padding:14px 18px; margin-bottom:10px;"> |
|
<strong>{{ category|capitalize }}:</strong> {{ message }} |
|
</div> |
|
{% endfor %} |
|
</section> |
|
{% endif %} |
|
{% endwith %} |
|
|
|
{% if files %} |
|
<section class="services-grid" style="grid-template-columns: 1fr;"> |
|
<article class="service-card status-beta"> |
|
<div class="service-card-header"> |
|
<div> |
|
<h2>{% if view_mode == 'gallery' %}Image Gallery{% else %}Files{% endif %}</h2> |
|
<p> |
|
{% if view_mode == 'gallery' %} |
|
Browse uploaded images visually. Click a thumbnail to preview the full-size image. |
|
{% else %} |
|
Select files to delete, download, or send to Zip Workspace. Rename changes only the customer-facing name, not the immutable stored file. |
|
{% endif %} |
|
</p> |
|
</div> |
|
<div> |
|
<span class="service-badge service-badge-beta">DB-backed</span> |
|
</div> |
|
</div> |
|
|
|
<div class="service-card-actions" style="overflow-x:auto;"> |
|
<form id="bulk-actions-form" method="post"> |
|
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;"> |
|
<button class="portal-btn primary" formaction="{{ url_for('main.send_selected_to_zip_workspace', device_id=device.id) }}" type="submit">Send to Zip Workspace</button> |
|
<button class="portal-btn" formaction="{{ url_for('main.download_selected_files', device_id=device.id) }}" type="submit">Download Selected</button> |
|
<button class="portal-btn" formaction="{{ url_for('main.delete_selected_files', device_id=device.id) }}" type="submit" onclick="return confirm('Delete selected files? They will move to the deleted area for up to 24 hours unless hard-deleted.');">Delete Selected</button> |
|
</div> |
|
</form> |
|
|
|
{% if view_mode == 'gallery' %} |
|
<div class="otb-gallery-grid"> |
|
{% for file in files %} |
|
{% set visible_name = file.display_filename or file.original_filename %} |
|
{% set rename_value = file.display_filename.rsplit('.', 1)[0] if file.display_filename and '.' in file.display_filename else file.basename %} |
|
{% set ext = (file.extension or '')|lower %} |
|
{% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %} |
|
<div class="otb-gallery-card" title="Name: {{ visible_name }} Original: {{ file.original_filename }} Type: {{ file.mime_type or file.file_kind }} Size: {{ '{:,}'.format(file.size_bytes or 0) }} bytes Uploaded: {{ file.uploaded_at }}"> |
|
<div class="otb-gallery-thumb-wrap"> |
|
<input class="row-check otb-gallery-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form"> |
|
{% if is_image %} |
|
<img |
|
src="{{ url_for('main.thumbnail_file', file_id=file.id) }}" |
|
alt="{{ visible_name }}" |
|
class="otb-gallery-thumb preview-trigger" |
|
data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}" |
|
data-preview-title="{{ visible_name }}" |
|
> |
|
{% else %} |
|
<div style="padding:20px;text-align:center;opacity:0.75;">No preview</div> |
|
{% endif %} |
|
</div> |
|
|
|
<div class="otb-gallery-meta"> |
|
<div class="otb-gallery-name">{{ visible_name }}</div> |
|
<div class="otb-gallery-sub"> |
|
{{ file.mime_type or file.file_kind }}<br> |
|
{{ "{:,}".format(file.size_bytes or 0) }} bytes |
|
</div> |
|
|
|
<div class="otb-gallery-actions"> |
|
{% if is_image %} |
|
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}">Preview</button> |
|
{% endif %} |
|
<a class="portal-btn" href="{{ url_for('main.download_file', file_id=file.id) }}">Download</a> |
|
</div> |
|
|
|
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-gallery-rename"> |
|
<input |
|
type="text" |
|
name="display_basename" |
|
value="{{ rename_value }}" |
|
maxlength="200" |
|
placeholder="Custom file name" |
|
> |
|
{% if file.extension %} |
|
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span> |
|
{% endif %} |
|
<button class="portal-btn" type="submit">Rename</button> |
|
</form> |
|
</div> |
|
</div> |
|
{% endfor %} |
|
</div> |
|
{% else %} |
|
<table style="width:100%;border-collapse:collapse;"> |
|
<thead> |
|
<tr> |
|
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);width:36px;"> |
|
<input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);"> |
|
</th> |
|
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Name</th> |
|
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Kind</th> |
|
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th> |
|
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Uploaded</th> |
|
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Path</th> |
|
</tr> |
|
</thead> |
|
<tbody> |
|
{% for file in files %} |
|
{% set visible_name = file.display_filename or file.original_filename %} |
|
{% set rename_value = file.display_filename.rsplit('.', 1)[0] if file.display_filename and '.' in file.display_filename else file.basename %} |
|
{% set ext = (file.extension or '')|lower %} |
|
{% set is_image = (file.mime_type and file.mime_type.startswith('image/')) or ext in ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] %} |
|
<tr> |
|
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> |
|
<input class="row-check" type="checkbox" name="selected_files" value="{{ file.id }}" form="bulk-actions-form"> |
|
</td> |
|
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> |
|
<div class="otb-list-name-wrap"> |
|
<div> |
|
<strong>{{ visible_name }}</strong><br> |
|
{% if file.display_filename %} |
|
<span style="opacity:0.75;font-size:0.9rem;">Original: {{ file.original_filename }}</span><br> |
|
{% endif %} |
|
{% if is_image %} |
|
<button class="portal-btn preview-trigger" type="button" data-preview-url="{{ url_for('main.inline_file', file_id=file.id) }}" data-preview-title="{{ visible_name }}" style="margin-top:8px;">Preview</button> |
|
{% endif %} |
|
<span style="opacity:0.75;font-size:0.9rem;">SHA256: {{ file.sha256 }}</span> |
|
</div> |
|
|
|
<form method="post" action="{{ url_for('main.rename_file', file_id=file.id) }}" class="otb-list-rename"> |
|
<input |
|
type="text" |
|
name="display_basename" |
|
value="{{ rename_value }}" |
|
maxlength="200" |
|
placeholder="Enter custom file name" |
|
> |
|
{% if file.extension %} |
|
<span style="opacity:0.8;font-size:0.95rem;">.{{ file.extension }}</span> |
|
{% endif %} |
|
<button class="portal-btn" type="submit">Rename</button> |
|
</form> |
|
</div> |
|
</td> |
|
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> |
|
{{ file.file_kind }} |
|
{% if file.is_immutable %} |
|
<br><span style="opacity:0.75;font-size:0.9rem;">immutable</span> |
|
{% endif %} |
|
</td> |
|
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> |
|
{{ "{:,}".format(file.size_bytes or 0) }} bytes |
|
</td> |
|
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> |
|
{{ file.uploaded_at }} |
|
</td> |
|
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;word-break:break-word;"> |
|
{{ file.relative_path }} |
|
</td> |
|
</tr> |
|
{% endfor %} |
|
</tbody> |
|
</table> |
|
{% endif %} |
|
</div> |
|
</article> |
|
</section> |
|
|
|
<div id="otb-image-modal" class="otb-modal-backdrop" aria-hidden="true"> |
|
<div class="otb-modal-card"> |
|
<div class="otb-modal-header"> |
|
<div id="otb-image-modal-title" class="otb-modal-title">Preview</div> |
|
<button type="button" class="portal-btn" id="otb-image-modal-close">Close</button> |
|
</div> |
|
<div class="otb-modal-body"> |
|
<img id="otb-image-modal-img" src="" alt=""> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
(function () { |
|
const modal = document.getElementById('otb-image-modal'); |
|
const modalImg = document.getElementById('otb-image-modal-img'); |
|
const modalTitle = document.getElementById('otb-image-modal-title'); |
|
const closeBtn = document.getElementById('otb-image-modal-close'); |
|
|
|
if (!modal || !modalImg || !modalTitle || !closeBtn) return; |
|
|
|
function openPreview(url, title) { |
|
modalImg.src = url; |
|
modalImg.alt = title || 'Preview image'; |
|
modalTitle.textContent = title || 'Preview'; |
|
modal.classList.add('active'); |
|
modal.setAttribute('aria-hidden', 'false'); |
|
} |
|
|
|
function closePreview() { |
|
modal.classList.remove('active'); |
|
modal.setAttribute('aria-hidden', 'true'); |
|
modalImg.src = ''; |
|
modalImg.alt = ''; |
|
} |
|
|
|
document.querySelectorAll('.preview-trigger').forEach(function (el) { |
|
el.addEventListener('click', function () { |
|
const url = this.dataset.previewUrl || this.getAttribute('src'); |
|
const title = this.dataset.previewTitle || this.getAttribute('alt') || 'Preview'; |
|
if (url) openPreview(url, title); |
|
}); |
|
}); |
|
|
|
closeBtn.addEventListener('click', closePreview); |
|
|
|
modal.addEventListener('click', function (e) { |
|
if (e.target === modal) closePreview(); |
|
}); |
|
|
|
document.addEventListener('keydown', function (e) { |
|
if (e.key === 'Escape') closePreview(); |
|
}); |
|
})(); |
|
</script> |
|
{% else %} |
|
<section class="services-grid"> |
|
<article class="service-card status-beta"> |
|
<div class="service-card-header"> |
|
<div> |
|
<h2>No files yet</h2> |
|
<p>This device does not have any uploaded files recorded yet.</p> |
|
</div> |
|
<div> |
|
<span class="service-badge service-badge-beta">Empty</span> |
|
</div> |
|
</div> |
|
|
|
<div class="service-card-actions"> |
|
<a class="portal-btn primary" href="{{ url_for('main.upload_files', device_id=device.id) }}">Upload Files</a> |
|
</div> |
|
</article> |
|
</section> |
|
{% endif %} |
|
{% endblock %}
|
|
|