otb-cloud secure encrypted backups
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.
 
 
 
 
 

571 lines
19 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-breadcrumbs {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 10px;
font-size: 0.95rem;
}
.otb-breadcrumbs a {
text-decoration: none;
}
.otb-folder-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.otb-folder-card {
display: block;
padding: 16px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.10);
background: rgba(255,255,255,0.04);
text-decoration: none;
color: inherit;
}
.otb-folder-card:hover {
background: rgba(255,255,255,0.07);
}
.otb-folder-name {
font-weight: 700;
word-break: break-word;
}
.otb-folder-sub {
opacity: 0.75;
font-size: 0.92rem;
margin-top: 6px;
}
.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 class="otb-breadcrumbs">
{% for crumb in breadcrumbs %}
<a class="portal-btn {% if loop.last %}primary{% endif %}" href="{{ url_for('main.browse_device_files', device_id=device.id, view=view_mode, path=crumb.path) }}">{{ crumb.label }}</a>
{% endfor %}
{% if current_path %}
<a class="portal-btn" href="{{ url_for('main.browse_device_files', device_id=device.id, view=view_mode, path=parent_path) }}">Up One Level</a>
{% endif %}
</div>
</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') }}">Archive 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', path=current_path) }}">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', path=current_path) }}">Gallery View</a>
</div>
<div style="text-align:right;font-size:14px;opacity:0.95;">
<div>Current folder file count: <strong>{{ file_count }}</strong></div>
<div>Subfolders here: <strong>{{ folders|length }}</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 %}
<section class="services-grid" style="grid-template-columns: 1fr;">
<article class="service-card status-beta">
<div class="service-card-header">
<div>
<h2>Folders</h2>
<p>Navigate the preserved backup structure like a normal file browser.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Tree View</span>
</div>
</div>
{% if folders %}
<div class="otb-folder-grid">
{% for folder in folders %}
<a class="otb-folder-card" href="{{ url_for('main.browse_device_files', device_id=device.id, view=view_mode, path=folder.path) }}">
<div class="otb-folder-name">📁 {{ folder.name }}</div>
<div class="otb-folder-sub">Open folder</div>
</a>
{% endfor %}
</div>
{% else %}
<div style="opacity:0.8;margin-bottom:16px;">No subfolders in this location.</div>
{% endif %}
</article>
</section>
{% 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' %}Current Folder Gallery{% else %}Current Folder Files{% endif %}</h2>
<p>
{% if view_mode == 'gallery' %}
Gallery view is scoped to the current folder only.
{% else %}
Bulk actions and rename apply to files in the current folder only.
{% endif %}
</p>
</div>
<div>
<span class="service-badge service-badge-beta">Scoped</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 Archive 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>
<button class="portal-btn primary" type="button" onclick="sendToWorkshop()">Send to Workshop</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 }}&#10;Original: {{ file.original_filename }}&#10;Type: {{ file.mime_type or file.file_kind }}&#10;Size: {{ '{:,}'.format(file.size_bytes or 0) }} bytes&#10;Uploaded: {{ file.uploaded_at }}">
<div class="otb-gallery-thumb-wrap">
<input class="row-check otb-gallery-check" type="checkbox" name="selected_files" value="{{ file.original_filename }}" 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>Select</th>
<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><th>Select</th>
<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.original_filename }}" 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>
<br>
{% 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 in this folder</h2>
<p>This location does not currently contain any active files.</p>
</div>
<div>
<span class="service-badge service-badge-beta">Empty</span>
</div>
</div>
</article>
</section>
{% endif %}
<script>
window.sendToWorkshop = function () {
const checked = Array.from(
document.querySelectorAll("input[name='selected_files']:checked, input.row-check:checked")
)
.map(cb => cb.value)
.filter(v => v && v !== "on");
console.log("workshop checked =", checked);
if (checked.length === 0) {
alert("No files selected");
return;
}
const parts = window.location.pathname.split("/");
const deviceId = parts[2];
localStorage.setItem("videoSelection", JSON.stringify(checked));
window.location.href = "/workshop/" + deviceId;
};
</script>
{% endblock %}