Add NextSnap PWA with photo gallery viewer and continuous capture
Offline-first photo capture app for Nextcloud with: - Camera capture with continuous mode (auto-reopens after each photo) - File browser with fullscreen image gallery, swipe navigation, and rename - Upload queue with background sync engine - Admin panel for Nextcloud user management - Service worker for offline-first caching (v13) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
369
app/templates/admin.html
Normal file
369
app/templates/admin.html
Normal file
@@ -0,0 +1,369 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin - NextSnap{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="admin-header">
|
||||
<h2>Admin Panel</h2>
|
||||
<button class="btn btn-secondary btn-small" id="refresh-btn">
|
||||
<span>🔄</span> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add User Form -->
|
||||
<div class="admin-section">
|
||||
<h3>Add New User</h3>
|
||||
<form id="add-user-form" class="user-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="new-username">Username *</label>
|
||||
<input type="text" id="new-username" required placeholder="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-password">Password *</label>
|
||||
<input type="password" id="new-password" required placeholder="••••••••">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="new-email">Email</label>
|
||||
<input type="email" id="new-email" placeholder="user@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-displayname">Display Name</label>
|
||||
<input type="text" id="new-displayname" placeholder="John Doe">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-error" id="form-error" style="display: none;"></div>
|
||||
<div class="form-success" id="form-success" style="display: none;"></div>
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">Create User</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- User List -->
|
||||
<div class="admin-section">
|
||||
<h3>Nextcloud Users</h3>
|
||||
<div class="loading-msg" id="loading-msg" style="display: none;">Loading users...</div>
|
||||
<div class="error-msg" id="error-msg" style="display: none;"></div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Display Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="user-list">
|
||||
<tr><td colspan="5" class="empty-state">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal" id="delete-modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<h3>Delete User?</h3>
|
||||
<p>Are you sure you want to delete user <strong id="delete-username"></strong>?</p>
|
||||
<p class="warning-text">⚠️ This action cannot be undone. All user data will be permanently deleted.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="Admin.hideDeleteModal()">Cancel</button>
|
||||
<button class="btn btn-danger" id="confirm-delete">Delete User</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div id="toast" class="toast" style="display: none;"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.admin-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.admin-section h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.user-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.loading-msg,
|
||||
.error-msg {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: var(--error);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.user-table thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.user-table th {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.user-table td {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.user-table tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem !important;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-action:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--warning);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: var(--warning) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 8rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
z-index: 10000;
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.user-table th,
|
||||
.user-table td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
<script>
|
||||
Admin.init();
|
||||
|
||||
// Close delete modal on outside click
|
||||
document.getElementById('delete-modal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'delete-modal') {
|
||||
Admin.hideDeleteModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
102
app/templates/base.html
Normal file
102
app/templates/base.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="theme-color" content="#16213e">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||
|
||||
<!-- iOS PWA Support -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="NextSnap">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/icon-192.png') }}">
|
||||
|
||||
<title>{% block title %}NextSnap{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Top Bar -->
|
||||
<header class="top-bar">
|
||||
<div class="top-bar-content">
|
||||
<h1 class="app-title">NextSnap</h1>
|
||||
<div class="top-bar-indicators">
|
||||
<div class="connectivity-indicator online" title="Online"></div>
|
||||
<div class="pending-count" id="pendingCount" style="display: none;">
|
||||
<span id="pendingCountValue">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main App Content -->
|
||||
<main id="app">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Bottom Navigation (for authenticated pages) -->
|
||||
{% if show_nav %}
|
||||
<nav class="bottom-nav">
|
||||
<a href="/capture" class="nav-item {% if request.path == '/capture' %}active{% endif %}">
|
||||
<span class="nav-icon">📷</span>
|
||||
<span class="nav-label">Capture</span>
|
||||
</a>
|
||||
<a href="/queue" class="nav-item {% if request.path == '/queue' %}active{% endif %}">
|
||||
<span class="nav-icon">📤</span>
|
||||
<span class="nav-label">Queue</span>
|
||||
</a>
|
||||
<a href="/browser" class="nav-item {% if request.path == '/browser' %}active{% endif %}">
|
||||
<span class="nav-icon">📁</span>
|
||||
<span class="nav-label">Files</span>
|
||||
</a>
|
||||
{% if is_admin %}
|
||||
<a href="/admin" class="nav-item {% if request.path == '/admin' %}active{% endif %}">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span class="nav-label">Admin</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<!-- Service Worker Registration -->
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/static/sw.js')
|
||||
.then((registration) => {
|
||||
console.log('Service Worker registered:', registration.scope);
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update();
|
||||
}, 60000); // Check every minute
|
||||
|
||||
// Handle updates
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// New service worker available
|
||||
console.log('New service worker available');
|
||||
// Could show update notification here
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Service Worker registration failed:', error);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.warn('Service Workers not supported in this browser');
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/polish.js') }}"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
484
app/templates/browser.html
Normal file
484
app/templates/browser.html
Normal file
@@ -0,0 +1,484 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Files - NextSnap{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>File Browser</h1>
|
||||
<button class="btn btn-primary btn-small" id="new-folder-btn">+ New Folder</button>
|
||||
</div>
|
||||
|
||||
<div class="breadcrumb" id="breadcrumb">
|
||||
<span class="breadcrumb-item">Loading...</span>
|
||||
</div>
|
||||
|
||||
<div class="file-list" id="file-list">
|
||||
<p class="loading">Loading...</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Gallery Overlay -->
|
||||
<div class="gallery-overlay" id="gallery-overlay" style="display:none">
|
||||
<div class="gallery-header">
|
||||
<button class="gallery-btn gallery-close-btn" id="gallery-close-btn">×</button>
|
||||
<span class="gallery-counter" id="gallery-counter">1 / 1</span>
|
||||
<button class="gallery-btn gallery-rename-btn" id="gallery-rename-btn">Rename</button>
|
||||
</div>
|
||||
|
||||
<div class="gallery-image-container" id="gallery-image-container">
|
||||
<div class="gallery-spinner" id="gallery-spinner">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<img class="gallery-image" id="gallery-image" alt="" />
|
||||
</div>
|
||||
|
||||
<button class="gallery-nav gallery-nav-left" id="gallery-prev-btn">‹</button>
|
||||
<button class="gallery-nav gallery-nav-right" id="gallery-next-btn">›</button>
|
||||
|
||||
<div class="gallery-filename-bar" id="gallery-filename-bar">
|
||||
<span id="gallery-filename"></span>
|
||||
</div>
|
||||
|
||||
<!-- Gallery toast -->
|
||||
<div class="gallery-toast" id="gallery-toast"></div>
|
||||
</div>
|
||||
|
||||
<!-- Rename Modal -->
|
||||
<div class="gallery-rename-backdrop" id="gallery-rename-backdrop" style="display:none">
|
||||
<div class="gallery-rename-modal">
|
||||
<h3>Rename File</h3>
|
||||
<div class="gallery-rename-input-row">
|
||||
<input type="text" id="gallery-rename-input" class="gallery-rename-input" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
||||
<span class="gallery-rename-ext" id="gallery-rename-ext">.jpg</span>
|
||||
</div>
|
||||
<div class="gallery-rename-error" id="gallery-rename-error"></div>
|
||||
<div class="gallery-rename-buttons">
|
||||
<button class="btn gallery-rename-cancel" id="gallery-rename-cancel">Cancel</button>
|
||||
<button class="btn btn-primary gallery-rename-save" id="gallery-rename-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
padding: 0.75rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb-item.current {
|
||||
color: var(--text-primary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.breadcrumb-item.current:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
|
||||
.file-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.file-item:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-select-folder {
|
||||
margin-top: 1rem;
|
||||
position: sticky;
|
||||
bottom: 5rem;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state,
|
||||
.error-state {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Gallery Overlay */
|
||||
body.gallery-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gallery-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gallery-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gallery-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gallery-close-btn {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gallery-counter {
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gallery-rename-btn {
|
||||
font-size: 0.95rem;
|
||||
color: var(--accent, #4dabf7);
|
||||
}
|
||||
|
||||
.gallery-image-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.gallery-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.gallery-spinner {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gallery-spinner .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255,255,255,0.2);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: gallery-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gallery-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.gallery-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 2.5rem;
|
||||
padding: 1rem 0.5rem;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
cursor: pointer;
|
||||
z-index: 3;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gallery-nav:disabled {
|
||||
opacity: 0.2;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gallery-nav-left {
|
||||
left: 0.5rem;
|
||||
}
|
||||
|
||||
.gallery-nav-right {
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
.gallery-filename-bar {
|
||||
padding: 0.75rem 1rem;
|
||||
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
|
||||
background: rgba(0,0,0,0.7);
|
||||
color: #ccc;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.gallery-toast {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
z-index: 10001;
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gallery-toast.success {
|
||||
background: rgba(40, 167, 69, 0.9);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.gallery-toast.error {
|
||||
background: rgba(220, 53, 69, 0.9);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Rename Modal */
|
||||
.gallery-rename-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.gallery-rename-modal {
|
||||
background: var(--bg-secondary, #2a2a3e);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.gallery-rename-modal h3 {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.gallery-rename-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.gallery-rename-input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--bg-tertiary, #444);
|
||||
border-radius: 8px 0 0 8px;
|
||||
background: var(--bg-primary, #1a1a2e);
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gallery-rename-input:focus {
|
||||
border-color: var(--accent, #4dabf7);
|
||||
}
|
||||
|
||||
.gallery-rename-ext {
|
||||
padding: 0.75rem 0.75rem;
|
||||
background: var(--bg-tertiary, #444);
|
||||
color: var(--text-secondary, #aaa);
|
||||
border: 1px solid var(--bg-tertiary, #444);
|
||||
border-left: none;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gallery-rename-error {
|
||||
color: var(--error, #ff6b6b);
|
||||
font-size: 0.85rem;
|
||||
min-height: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.gallery-rename-buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.gallery-rename-cancel,
|
||||
.gallery-rename-save {
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.gallery-rename-cancel {
|
||||
background: var(--bg-tertiary, #444);
|
||||
color: var(--text-primary, #fff);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.gallery-rename-save {
|
||||
min-width: 80px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/filebrowser.js') }}"></script>
|
||||
<script>
|
||||
document.getElementById('logout-btn').addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
if (confirm('Are you sure you want to logout?')) {
|
||||
await Auth.logout();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
407
app/templates/capture.html
Normal file
407
app/templates/capture.html
Normal file
@@ -0,0 +1,407 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Capture - NextSnap{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="capture-section">
|
||||
<p class="upload-path">
|
||||
Uploading to: <strong><span id="current-path">/</span></strong>
|
||||
<a href="/browser" class="change-link">Change</a>
|
||||
</p>
|
||||
|
||||
<button class="btn btn-primary btn-capture" id="capture-btn">
|
||||
📷 Take Photo
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="camera-input"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
style="display: none;"
|
||||
>
|
||||
|
||||
<!-- Session counter shown during continuous capture -->
|
||||
<div class="capture-session-bar" id="capture-session-bar" style="display:none">
|
||||
<span id="session-count">0</span> photos this session
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recent-photos" id="recent-photos">
|
||||
<h3>Recent Photos</h3>
|
||||
<div class="photo-thumbnails" id="photo-thumbnails">
|
||||
<p class="empty-state">No photos captured yet</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capture toast -->
|
||||
<div class="capture-toast" id="capture-toast"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.capture-section {
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.upload-path {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.upload-path strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.change-link {
|
||||
color: var(--accent);
|
||||
margin-left: 0.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.change-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-capture {
|
||||
font-size: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.btn-capture:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.capture-session-bar {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.capture-toast {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
z-index: 9998;
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
background: rgba(40, 167, 69, 0.9);
|
||||
color: #fff;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.recent-photos {
|
||||
margin-top: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.recent-photos h3 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.photo-thumbnails {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.photo-thumbnails .empty-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumbnail .status-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-primary);
|
||||
}
|
||||
|
||||
.thumbnail .status-badge.pending {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.thumbnail .status-badge.uploading {
|
||||
background: var(--accent);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.thumbnail .status-badge.verified {
|
||||
background: var(--success);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='lib/dexie.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/storage.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/sync.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
<script>
|
||||
const currentUsername = '{{ username }}';
|
||||
|
||||
function updateUploadPath() {
|
||||
const savedPath = localStorage.getItem('nextsnap_upload_path') || '/';
|
||||
document.getElementById('current-path').textContent = savedPath;
|
||||
}
|
||||
updateUploadPath();
|
||||
|
||||
let storageReady = false;
|
||||
Storage.init().then(() => {
|
||||
storageReady = true;
|
||||
SyncEngine.init(currentUsername);
|
||||
loadRecentPhotos();
|
||||
}).catch(err => console.error('Storage init failed:', err));
|
||||
|
||||
const captureBtn = document.getElementById('capture-btn');
|
||||
const cameraInput = document.getElementById('camera-input');
|
||||
|
||||
// Continuous capture state
|
||||
let sessionCount = 0;
|
||||
let continuousCapture = false;
|
||||
let processingPhoto = false;
|
||||
|
||||
captureBtn.addEventListener('click', function() {
|
||||
sessionCount = 0;
|
||||
updateSessionCounter();
|
||||
cameraInput.click();
|
||||
});
|
||||
|
||||
cameraInput.addEventListener('change', async function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) {
|
||||
// User cancelled the camera — stop continuous capture
|
||||
continuousCapture = false;
|
||||
if (sessionCount > 0) {
|
||||
showCaptureToast(sessionCount + ' photo' + (sessionCount !== 1 ? 's' : '') + ' captured');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
processingPhoto = true;
|
||||
captureBtn.disabled = true;
|
||||
captureBtn.textContent = '⏳ Processing...';
|
||||
|
||||
try {
|
||||
const jpegBlob = await convertToJPEG(file);
|
||||
|
||||
const now = new Date();
|
||||
const timestamp = now.getFullYear() +
|
||||
String(now.getMonth() + 1).padStart(2, '0') +
|
||||
String(now.getDate()).padStart(2, '0') + '_' +
|
||||
String(now.getHours()).padStart(2, '0') +
|
||||
String(now.getMinutes()).padStart(2, '0') +
|
||||
String(now.getSeconds()).padStart(2, '0');
|
||||
const filename = currentUsername + '_' + timestamp + '.jpg';
|
||||
const targetPath = localStorage.getItem('nextsnap_upload_path') || '/';
|
||||
|
||||
if (storageReady) {
|
||||
await Storage.savePhoto({
|
||||
username: currentUsername,
|
||||
timestamp: Date.now(),
|
||||
filename: filename,
|
||||
targetPath: targetPath,
|
||||
blob: jpegBlob,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
if (navigator.onLine && typeof SyncEngine !== 'undefined') {
|
||||
SyncEngine.triggerSync();
|
||||
}
|
||||
|
||||
loadRecentPhotos();
|
||||
}
|
||||
|
||||
sessionCount++;
|
||||
continuousCapture = true;
|
||||
updateSessionCounter();
|
||||
showCaptureToast('Photo saved');
|
||||
|
||||
captureBtn.disabled = false;
|
||||
captureBtn.textContent = '📷 Take Photo';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
captureBtn.textContent = '❌ Error';
|
||||
continuousCapture = false;
|
||||
setTimeout(() => {
|
||||
captureBtn.disabled = false;
|
||||
captureBtn.textContent = '📷 Take Photo';
|
||||
}, 2000);
|
||||
alert('Failed: ' + error.message);
|
||||
}
|
||||
|
||||
processingPhoto = false;
|
||||
e.target.value = '';
|
||||
|
||||
// Re-open camera automatically for next photo
|
||||
if (continuousCapture) {
|
||||
setTimeout(() => {
|
||||
cameraInput.click();
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
|
||||
function updateSessionCounter() {
|
||||
const bar = document.getElementById('capture-session-bar');
|
||||
const countEl = document.getElementById('session-count');
|
||||
if (sessionCount > 0) {
|
||||
countEl.textContent = sessionCount;
|
||||
bar.style.display = 'inline-block';
|
||||
} else {
|
||||
bar.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function showCaptureToast(msg) {
|
||||
const toast = document.getElementById('capture-toast');
|
||||
toast.textContent = '✓ ' + msg;
|
||||
toast.style.display = 'block';
|
||||
setTimeout(() => { toast.style.display = 'none'; }, 1500);
|
||||
}
|
||||
|
||||
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
function convertToJPEG(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
let quality = 0.92;
|
||||
let scale = 1.0;
|
||||
|
||||
const attempt = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(img.width * scale);
|
||||
canvas.height = Math.round(img.height * scale);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to convert'));
|
||||
return;
|
||||
}
|
||||
if (blob.size <= MAX_UPLOAD_BYTES || quality <= 0.3) {
|
||||
console.log('Photo: ' + (blob.size / 1024 / 1024).toFixed(1) + 'MB, ' + canvas.width + 'x' + canvas.height + ', q=' + quality.toFixed(2));
|
||||
resolve(blob);
|
||||
} else {
|
||||
console.log('Photo too large (' + (blob.size / 1024 / 1024).toFixed(1) + 'MB), reducing...');
|
||||
if (quality > 0.5) {
|
||||
quality -= 0.1;
|
||||
} else {
|
||||
scale *= 0.8;
|
||||
quality = 0.7;
|
||||
}
|
||||
attempt();
|
||||
}
|
||||
}, 'image/jpeg', quality);
|
||||
};
|
||||
|
||||
attempt();
|
||||
};
|
||||
img.onerror = () => reject(new Error('Failed to load'));
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRecentPhotos() {
|
||||
if (!storageReady) return;
|
||||
|
||||
const container = document.getElementById('photo-thumbnails');
|
||||
const photos = await Storage.getRecentPhotos(currentUsername, 5);
|
||||
|
||||
if (photos.length === 0) {
|
||||
container.innerHTML = '<p class="empty-state">No photos captured yet</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
for (const photo of photos) {
|
||||
const url = URL.createObjectURL(photo.blob);
|
||||
const statusClass = photo.status === 'verified' ? 'verified' :
|
||||
photo.status === 'uploading' ? 'uploading' : 'pending';
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'thumbnail';
|
||||
div.innerHTML = '<img src="' + url + '"><span class="status-badge ' + statusClass + '"></span>';
|
||||
container.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Capture page loaded');
|
||||
|
||||
// Debug panel
|
||||
setTimeout(() => {
|
||||
const debugDiv = document.createElement('div');
|
||||
debugDiv.id = 'debug-panel';
|
||||
debugDiv.style.cssText = 'position:fixed;bottom:80px;right:10px;z-index:9999;background:black;color:lime;padding:10px;border-radius:5px;font-family:monospace;font-size:10px;max-width:300px;';
|
||||
|
||||
const status = [];
|
||||
status.push('Storage: ' + (typeof Storage !== 'undefined' ? 'OK' : 'FAIL'));
|
||||
status.push('SyncEngine: ' + (typeof SyncEngine !== 'undefined' ? 'OK' : 'FAIL'));
|
||||
status.push('Dexie: ' + (typeof Dexie !== 'undefined' ? 'OK' : 'FAIL'));
|
||||
|
||||
debugDiv.innerHTML = '<div style="margin-bottom:5px;">' + status.join('<br>') + '</div>' +
|
||||
'<button onclick="manualSync()" style="background:lime;color:black;border:none;padding:10px;border-radius:5px;cursor:pointer;font-weight:bold;width:100%;">FORCE SYNC</button>' +
|
||||
'<div id="sync-status" style="margin-top:5px;color:yellow;"></div>';
|
||||
document.body.appendChild(debugDiv);
|
||||
}, 2000);
|
||||
|
||||
window.manualSync = function() {
|
||||
const statusDiv = document.getElementById('sync-status');
|
||||
statusDiv.textContent = 'Checking...';
|
||||
|
||||
if (typeof SyncEngine === 'undefined') {
|
||||
statusDiv.textContent = 'ERROR: SyncEngine not loaded!';
|
||||
return;
|
||||
}
|
||||
|
||||
statusDiv.textContent = 'Triggering sync...';
|
||||
try {
|
||||
SyncEngine.triggerSync();
|
||||
statusDiv.textContent = 'Sync triggered!';
|
||||
} catch (e) {
|
||||
statusDiv.textContent = 'ERROR: ' + e.message;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
208
app/templates/login.html
Normal file
208
app/templates/login.html
Normal file
@@ -0,0 +1,208 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - NextSnap{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<div class="app-icon">📷</div>
|
||||
<h1 class="login-title">NextSnap</h1>
|
||||
<p class="login-subtitle">Offline-first photo capture for Nextcloud</p>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Nextcloud Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
class="form-input"
|
||||
placeholder="Enter your username"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
autofocus
|
||||
required
|
||||
>
|
||||
<span class="field-error" id="username-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="form-input"
|
||||
placeholder="Enter your password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
>
|
||||
<span class="field-error" id="password-error"></span>
|
||||
</div>
|
||||
|
||||
<div id="error-message" class="error-message hidden"></div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-login" id="login-btn">
|
||||
<span id="login-btn-text">Login</span>
|
||||
<span id="login-btn-loading" class="hidden">
|
||||
<span class="spinner"></span> Logging in...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
<p class="help-text">
|
||||
<strong>Tip:</strong> Use your Nextcloud credentials to login
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
|
||||
}
|
||||
|
||||
.login-container {
|
||||
max-width: 400px;
|
||||
margin: 2rem auto;
|
||||
padding: 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--error);
|
||||
font-size: 0.85rem;
|
||||
min-height: 1.2rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 0.75rem;
|
||||
background: var(--error);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
margin-top: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 480px) {
|
||||
.login-container {
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
{% endblock %}
|
||||
617
app/templates/queue.html
Normal file
617
app/templates/queue.html
Normal file
@@ -0,0 +1,617 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Queue - NextSnap{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="queue-header">
|
||||
<h2>Upload Queue</h2>
|
||||
<div class="queue-actions">
|
||||
<button class="btn btn-secondary btn-small" id="sync-now-btn" disabled>
|
||||
<span class="btn-icon">🔄</span> Sync Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="queue-stats" id="queue-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value" id="pending-stat">0</span>
|
||||
<span class="stat-label">Pending</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value" id="uploading-stat">0</span>
|
||||
<span class="stat-label">Uploading</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value" id="total-size-stat">0 MB</span>
|
||||
<span class="stat-label">Total Size</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="queue-list" id="queue-list">
|
||||
<p class="empty-state" id="empty-state">No photos in queue</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal" id="delete-modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<h3>Delete Photo?</h3>
|
||||
<p>Are you sure you want to delete this photo from the queue?</p>
|
||||
<p class="warning-text">⚠️ This action cannot be undone.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="cancel-delete-btn">Cancel</button>
|
||||
<button class="btn btn-danger" id="confirm-delete-btn">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.queue-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.queue-header h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.queue-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.btn-secondary:active:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.queue-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.queue-item {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.queue-item.uploading {
|
||||
border: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.queue-item.verified {
|
||||
border: 1px solid var(--success, #4caf50);
|
||||
}
|
||||
|
||||
.queue-item.error {
|
||||
border: 2px solid var(--error);
|
||||
}
|
||||
|
||||
.queue-item.completed {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.queue-divider {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 1rem 0 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.thumbnail-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.queue-item-thumbnail {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.queue-item-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.queue-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-item-filename {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.queue-item-meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.queue-item-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: var(--warning);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-badge.uploading {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.verified {
|
||||
background: var(--success, #4caf50);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.failed {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.retry-info {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.queue-item-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.queue-item-delete {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.queue-item-delete:active {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.queue-item-retry {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.queue-item-retry:active {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: var(--warning) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='lib/dexie.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/storage.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/sync.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
|
||||
<script>
|
||||
const currentUsername = '{{ username }}';
|
||||
let deletePhotoId = null;
|
||||
|
||||
// Initialize storage and load queue
|
||||
Storage.init().then(() => {
|
||||
SyncEngine.init(currentUsername);
|
||||
loadQueue();
|
||||
updateStats();
|
||||
});
|
||||
|
||||
async function loadQueue() {
|
||||
const queueList = document.getElementById('queue-list');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
|
||||
const photos = await Storage.db.photos
|
||||
.where('username').equals(currentUsername)
|
||||
.sortBy('timestamp');
|
||||
|
||||
// Split into active (pending/uploading) and completed (verified/failed)
|
||||
const activePhotos = photos.filter(p => p.status === 'pending' || p.status === 'uploading');
|
||||
const completedPhotos = photos.filter(p => p.status === 'verified' || p.status === 'failed');
|
||||
|
||||
if (photos.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
queueList.innerHTML = '';
|
||||
queueList.appendChild(emptyState);
|
||||
document.getElementById('sync-now-btn').disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
queueList.innerHTML = '';
|
||||
|
||||
// Show active uploads first (newest first)
|
||||
for (const photo of activePhotos.reverse()) {
|
||||
queueList.appendChild(createQueueItem(photo, false));
|
||||
}
|
||||
|
||||
// Show completed history (newest first, last 20 kept by pruneHistory)
|
||||
if (completedPhotos.length > 0) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'queue-divider';
|
||||
divider.textContent = 'Recent Uploads';
|
||||
queueList.appendChild(divider);
|
||||
|
||||
for (const photo of completedPhotos.reverse()) {
|
||||
queueList.appendChild(createQueueItem(photo, true));
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('sync-now-btn').disabled = activePhotos.length === 0 || !navigator.onLine;
|
||||
}
|
||||
|
||||
function createQueueItem(photo, isCompleted) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'queue-item';
|
||||
|
||||
if (isCompleted) {
|
||||
item.classList.add('completed');
|
||||
}
|
||||
|
||||
if (photo.status === 'uploading') {
|
||||
item.classList.add('uploading');
|
||||
} else if (photo.status === 'verified') {
|
||||
item.classList.add('verified');
|
||||
} else if (photo.status === 'failed' || photo.lastError || photo.error) {
|
||||
item.classList.add('error');
|
||||
}
|
||||
|
||||
// Create thumbnail (use placeholder if blob was stripped)
|
||||
const thumbnail = document.createElement('div');
|
||||
thumbnail.className = 'queue-item-thumbnail';
|
||||
if (photo.blob && photo.blob.size > 0) {
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(photo.blob);
|
||||
thumbnail.appendChild(img);
|
||||
} else {
|
||||
thumbnail.innerHTML = '<span class="thumbnail-placeholder">' +
|
||||
(photo.status === 'verified' ? '\u2705' : '\u274C') + '</span>';
|
||||
}
|
||||
|
||||
// Create info section
|
||||
const info = document.createElement('div');
|
||||
info.className = 'queue-item-info';
|
||||
|
||||
const filename = document.createElement('div');
|
||||
filename.className = 'queue-item-filename';
|
||||
filename.textContent = photo.filename;
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'queue-item-meta';
|
||||
const date = new Date(photo.completedAt || photo.timestamp).toLocaleString();
|
||||
if (photo.blob && photo.blob.size > 0) {
|
||||
const size = (photo.blob.size / 1024 / 1024).toFixed(2);
|
||||
meta.textContent = size + ' MB \u2022 ' + date;
|
||||
} else {
|
||||
meta.textContent = date;
|
||||
}
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.className = 'queue-item-status';
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'status-badge ' + photo.status;
|
||||
badge.textContent = photo.status.charAt(0).toUpperCase() + photo.status.slice(1);
|
||||
status.appendChild(badge);
|
||||
|
||||
if (photo.retryCount > 0 && !isCompleted) {
|
||||
const retry = document.createElement('span');
|
||||
retry.className = 'retry-info';
|
||||
retry.textContent = 'Retry #' + photo.retryCount;
|
||||
status.appendChild(retry);
|
||||
}
|
||||
|
||||
info.appendChild(filename);
|
||||
info.appendChild(meta);
|
||||
info.appendChild(status);
|
||||
|
||||
const errorMsg = photo.lastError || photo.error;
|
||||
if (errorMsg && photo.status !== 'verified') {
|
||||
const error = document.createElement('div');
|
||||
error.className = 'error-message';
|
||||
error.textContent = errorMsg;
|
||||
info.appendChild(error);
|
||||
}
|
||||
|
||||
item.appendChild(thumbnail);
|
||||
item.appendChild(info);
|
||||
|
||||
// Only show action buttons for active (non-completed) photos
|
||||
if (!isCompleted) {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'queue-item-actions';
|
||||
|
||||
if (photo.status === 'failed' || photo.status === 'pending' || photo.lastError || photo.error) {
|
||||
const retryBtn = document.createElement('button');
|
||||
retryBtn.className = 'queue-item-retry';
|
||||
retryBtn.textContent = '\uD83D\uDD04';
|
||||
retryBtn.title = 'Retry upload';
|
||||
retryBtn.addEventListener('click', async () => {
|
||||
retryBtn.textContent = '\u23F3';
|
||||
retryBtn.disabled = true;
|
||||
await Storage.updatePhoto(photo.id, { status: 'pending', lastError: null, error: null, retryCount: 0 });
|
||||
if (typeof SyncEngine !== 'undefined') {
|
||||
SyncEngine.triggerSync();
|
||||
}
|
||||
setTimeout(() => loadQueue(), 500);
|
||||
});
|
||||
actions.appendChild(retryBtn);
|
||||
}
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'queue-item-delete';
|
||||
deleteBtn.textContent = '\uD83D\uDDD1\uFE0F';
|
||||
deleteBtn.title = 'Delete photo';
|
||||
deleteBtn.addEventListener('click', () => showDeleteModal(photo.id));
|
||||
actions.appendChild(deleteBtn);
|
||||
|
||||
item.appendChild(actions);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
async function updateStats() {
|
||||
const photos = await Storage.db.photos
|
||||
.where('username').equals(currentUsername)
|
||||
.toArray();
|
||||
|
||||
const pendingCount = photos.filter(p => p.status === 'pending').length;
|
||||
const uploadingCount = photos.filter(p => p.status === 'uploading').length;
|
||||
const totalSize = photos
|
||||
.filter(p => p.blob && p.blob.size)
|
||||
.reduce((sum, p) => sum + p.blob.size, 0) / 1024 / 1024;
|
||||
|
||||
document.getElementById('pending-stat').textContent = pendingCount;
|
||||
document.getElementById('uploading-stat').textContent = uploadingCount;
|
||||
document.getElementById('total-size-stat').textContent = totalSize.toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function showDeleteModal(photoId) {
|
||||
deletePhotoId = photoId;
|
||||
document.getElementById('delete-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideDeleteModal() {
|
||||
deletePhotoId = null;
|
||||
document.getElementById('delete-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
document.getElementById('sync-now-btn').addEventListener('click', async () => {
|
||||
if (navigator.onLine) {
|
||||
await SyncEngine.triggerSync();
|
||||
setTimeout(() => {
|
||||
loadQueue();
|
||||
updateStats();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('cancel-delete-btn').addEventListener('click', hideDeleteModal);
|
||||
|
||||
document.getElementById('confirm-delete-btn').addEventListener('click', async () => {
|
||||
if (deletePhotoId) {
|
||||
await Storage.deletePhoto(deletePhotoId);
|
||||
hideDeleteModal();
|
||||
loadQueue();
|
||||
updateStats();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on outside click
|
||||
document.getElementById('delete-modal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'delete-modal') {
|
||||
hideDeleteModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for sync updates
|
||||
window.addEventListener('online', () => {
|
||||
document.getElementById('sync-now-btn').disabled = false;
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
document.getElementById('sync-now-btn').disabled = true;
|
||||
});
|
||||
|
||||
// Refresh queue periodically
|
||||
setInterval(() => {
|
||||
loadQueue();
|
||||
updateStats();
|
||||
}, 5000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
306
app/templates/reviewer.html
Normal file
306
app/templates/reviewer.html
Normal file
@@ -0,0 +1,306 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Review Photos - NextSnap{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="photo-viewer" class="photo-viewer">
|
||||
<!-- Top Controls -->
|
||||
<div class="viewer-header">
|
||||
<button class="viewer-btn" id="done-btn">Done</button>
|
||||
<div class="photo-position" id="photo-position">1 / 1</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Photo Display -->
|
||||
<div class="photo-container">
|
||||
<div class="photo-spinner" id="photo-spinner">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<img id="current-photo" class="current-photo" alt="Photo">
|
||||
</div>
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<button class="nav-arrow nav-arrow-left" id="prev-btn" aria-label="Previous">←</button>
|
||||
<button class="nav-arrow nav-arrow-right" id="next-btn" aria-label="Next">→</button>
|
||||
|
||||
<!-- Filename Editor -->
|
||||
<div class="filename-editor">
|
||||
<div class="filename-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
id="filename-input"
|
||||
class="filename-input"
|
||||
placeholder="Enter filename"
|
||||
>
|
||||
<span class="filename-extension">.jpg</span>
|
||||
<div class="save-spinner" id="save-spinner" style="display: none;">
|
||||
<div class="spinner-small"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div id="toast" class="toast" style="display: none;"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo-viewer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #000;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.viewer-btn {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: 1px solid white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.viewer-btn:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.photo-position {
|
||||
color: white;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.photo-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
touch-action: pan-y pinch-zoom;
|
||||
}
|
||||
|
||||
.current-photo {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.photo-spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.nav-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
z-index: 5;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.nav-arrow:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.nav-arrow:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nav-arrow-left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.nav-arrow-right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.filename-editor {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
padding: 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.filename-input-wrapper {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.filename-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filename-extension {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.save-spinner {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 8rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
z-index: 10000;
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 480px) {
|
||||
.viewer-header {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.nav-arrow {
|
||||
font-size: 1.5rem;
|
||||
padding: 0.75rem;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.filename-input {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='lib/dexie.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/storage.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/reviewer.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
|
||||
<script>
|
||||
// Get parameters from URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const mode = urlParams.get('mode') || 'local';
|
||||
const path = urlParams.get('path') || null;
|
||||
const currentUsername = '{{ username }}';
|
||||
|
||||
async function initReviewer() {
|
||||
await Storage.init();
|
||||
|
||||
let photos = [];
|
||||
|
||||
if (mode === 'local') {
|
||||
// Load pending photos from IndexedDB
|
||||
photos = await Storage.db.photos
|
||||
.where('username').equals(currentUsername)
|
||||
.and(p => p.status === 'pending' || p.status === 'uploading')
|
||||
.sortBy('timestamp');
|
||||
} else {
|
||||
// Load photos from Nextcloud folder
|
||||
// This would be called from the file browser with a list of photos
|
||||
const photoList = sessionStorage.getItem('reviewer_photos');
|
||||
if (photoList) {
|
||||
photos = JSON.parse(photoList);
|
||||
sessionStorage.removeItem('reviewer_photos');
|
||||
}
|
||||
}
|
||||
|
||||
Reviewer.init(mode, currentUsername, photos, path);
|
||||
}
|
||||
|
||||
initReviewer();
|
||||
</script>
|
||||
{% endblock %}
|
||||
66
app/templates/test.html
Normal file
66
app/templates/test.html
Normal file
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Camera Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
#test-btn {
|
||||
font-size: 24px;
|
||||
padding: 30px 60px;
|
||||
margin: 20px;
|
||||
background: blue;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#status {
|
||||
margin: 20px;
|
||||
padding: 10px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Camera Test Page</h1>
|
||||
<div id="status">Click counter: <span id="count">0</span></div>
|
||||
|
||||
<button id="test-btn">📷 TEST BUTTON</button>
|
||||
|
||||
<input type="file" id="camera-input" accept="image/*" capture="environment" style="display:none;">
|
||||
|
||||
<div id="result"></div>
|
||||
|
||||
<script>
|
||||
let clickCount = 0;
|
||||
const btn = document.getElementById('test-btn');
|
||||
const input = document.getElementById('camera-input');
|
||||
const countEl = document.getElementById('count');
|
||||
const resultEl = document.getElementById('result');
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
clickCount++;
|
||||
countEl.textContent = clickCount;
|
||||
console.log('Button clicked!', clickCount);
|
||||
input.click();
|
||||
console.log('Input clicked');
|
||||
});
|
||||
|
||||
input.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
resultEl.textContent = 'File: ' + file.name + ', Size: ' + file.size;
|
||||
console.log('File selected:', file);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Test page loaded');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user