Replies: 5 comments 2 replies
-
Probably today, I have spent the last few days recording video docs (my camera is ass lol). its basically the current version, just a few minor teaks (mainly the ones you suggested). Ill get a rough version of the gallery upload up as well, I think I can do it without editing the backend and itll just be a new version of the gallery page. |
Beta Was this translation helpful? Give feedback.
-
ok here is an updated html you can use for gallery. it requires no backend update and should just run off the existing upload handler. It still needs you to refresh the media.json to actual see the files, but soon I will be working on dynamically updating the json for uploads/renames/etc. For now this should be a decent solution though. I would recommend making a backup of the SD card, despite my safeguards there is probably some weird combo of people uploading at once that could format the card or mess something up, I will do a bunch of tests on that, but real world use will defiantly find it if its there lol. <!DOCTYPE html>
<!-- Nomad Gallery Page -->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Jcorp Nomad Media Server – Images & Video" />
<title>Nomad • Gallery</title>
<link rel="icon" href="favicon.ico" type="image/x-icon" />
<style>
:root {
--primary: #007bff;
--primary-dark: #0056b3;
--bg: #f4f4f9;
--text: #333;
--radius: 8px;
--shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Arial, sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 0 0.5rem;
overflow-x: hidden;
}
header {
background: var(--primary);
color: #fff;
padding: 1.25rem 1rem;
margin-left: -0.5rem;
margin-right: -0.5rem;
padding-left: calc(1rem + 0.5rem);
padding-right: calc(1rem + 0.5rem);
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--shadow);
position: sticky;
top: 0;
z-index: 50;
flex-wrap: wrap;
gap: 0.5rem;
}
header h1 {
font-size: clamp(1.25rem, 5vw, 1.75rem);
font-weight: 600;
white-space: nowrap;
flex: 1 1 auto;
min-width: 150px;
}
.back-btn {
text-decoration: none;
color: #fff;
background: var(--primary-dark);
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
font-size: 0.9rem;
white-space: nowrap;
flex-shrink: 0;
}
.toolbar {
background: var(--bg);
padding: 0.75rem 1rem;
display: flex;
gap: 0.75rem;
align-items: center;
border-bottom: 1px solid #ddd;
position: sticky;
top: 64px;
z-index: 45;
justify-content: flex-end;
flex-wrap: wrap;
}
.toolbar select,
.toolbar input[type='number'],
.toolbar .view-btn {
padding: 0.5rem 0.75rem;
border: 1px solid #ccc;
background: #fff;
border-radius: var(--radius);
font-size: 0.9rem;
cursor: pointer;
min-width: 60px;
flex-shrink: 1;
}
.toolbar label {
font-size: 0.9rem;
color: var(--text);
display: flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
flex-shrink: 0;
}
.view-btn.active {
background: var(--primary);
color: #fff;
border-color: var(--primary-dark);
}
main {
flex: 1;
display: flex;
flex-direction: column;
max-width: 1200px;
width: 100%;
margin: 0 auto;
overflow-x: hidden;
}
.grid-wrapper {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.media-grid {
display: grid;
gap: 1rem;
justify-content: center;
width: 100%;
box-sizing: border-box;
}
.media-grid.small {
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); /* ≈ 3 per row */
}
.media-grid.medium {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); /* ≈ 2 per row */
}
.media-grid.large {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); /* ≈ 1–2 per row */
}
@media (max-width: 600px) {
header {
flex-direction: column;
gap: 0.5rem;
}
.back-btn {
width: 100%;
text-align: center;
}
.toolbar {
top: 112px;
justify-content: center;
gap: 0.5rem;
}
.toolbar label,
.toolbar select,
.toolbar input[type='number'],
.toolbar .view-btn {
flex: 1 1 100px;
min-width: 80px;
}
.media-grid.small {
grid-template-columns: repeat(3, 1fr); /* exactly 3 per row */
}
.media-grid.medium {
grid-template-columns: repeat(2, 1fr); /* exactly 2 per row */
}
.media-grid.large {
grid-template-columns: repeat(1, 1fr); /* exactly 1 per row */
}
}
.media-card {
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.25s ease;
cursor: pointer;
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
max-width: 100%;
box-sizing: border-box;
}
.play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0.75;
font-size: 3rem;
color: white;
text-shadow: 0 0 10px black;
z-index: 2;
background: rgba(0, 0, 0, 0.3);
border-radius: 50%;
padding: 0.3em 0.4em;
}
.media-card:hover {
transform: translateY(-3px);
}
.media-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
user-select: none;
pointer-events: none;
}
.modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
position: relative;
border-radius: var(--radius);
overflow: hidden;
width: 90vw;
height: 90vh;
padding: 1rem;
background: #000;
color: #fff;
display: flex;
flex-direction: column;
}
#modalMedia {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
#modalMedia img,
#modalMedia video {
width: 100%;
height: 100%;
object-fit: contain;
}
.modal-info {
flex: 0 0 auto;
padding: 1rem 0 0;
overflow-y: auto;
max-height: 25vh;
}
.modal-close {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border: none;
font-size: 1.5rem;
padding: 0.2rem 0.5rem;
border-radius: 50%;
cursor: pointer;
}
.pager {
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
.pager button {
padding: 0.5rem 0.75rem;
border: 1px solid #ccc;
background: #fff;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.9rem;
min-width: 80px;
flex-shrink: 1;
}
.pager button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
footer {
padding: 1rem;
text-align: center;
font-size: 0.9rem;
color: #666;
}
</style>
</head>
<body>
<header>
<h1>Gallery</h1>
<a class="back-btn" href="/menu">← Back to Menu</a>
</header>
<div class="toolbar">
<label>
Sort:
<select id="sortSelect">
<option value="nameAsc">Name A→Z</option>
<option value="nameDesc">Name Z→A</option>
<option value="typeAsc">Type ↑</option>
<option value="typeDesc">Type ↓</option>
<option value="sizeAsc">Size ↑</option>
<option value="sizeDesc">Size ↓</option>
</select>
</label>
<label>
Per page:
<input id="pageSizeInput" type="number" min="1" max="100" value="20" style="width:4rem;" />
</label>
<button class="view-btn" data-size="small" title="Small">🔳</button>
<button class="view-btn active" data-size="medium" title="Medium">🔲</button>
<button class="view-btn" data-size="large" title="Large">⬜</button>
</div>
<main>
<div class="grid-wrapper">
<div id="grid" class="media-grid medium"></div>
</div>
<div class="pager">
<button id="prevBtn">Prev</button>
<span id="pageInfo"></span>
<button id="nextBtn">Next</button>
</div>
</main>
<div id="modal" class="modal" onclick="closeModal(event)">
<div class="modal-content" onclick="event.stopPropagation()">
<button class="modal-close" onclick="closeModal(event)">×</button>
<div id="modalMedia"></div>
<div id="modalInfo" class="modal-info"></div>
</div>
</div>
<footer>
<div style="display:flex;flex-direction:column;align-items:center;gap:0.5rem;">
<button id="btn-upload" style="
background: var(--primary);
color: #fff;
border: none;
padding: 0.5rem 1rem;
border-radius: var(--radius);
cursor: pointer;
">⬆ Upload to Gallery</button>
<input id="upload-input" type="file" style="display:none" accept="image/*,video/*" multiple>
<div id="upload-progress" style="
display:none;
width:100%;
max-width:300px;
height:10px;
background:#ddd;
border-radius:5px;
overflow:hidden;
">
<div id="upload-bar" style="
width:0%;
height:100%;
background:var(--primary);
transition:width 0.2s;
"></div>
</div>
<span id="upload-percent" style="font-size:0.85rem;color:#555;display:none;">0%</span>
<small>© <span id="year"></span> Jcorp Nomad</small>
</div>
</footer>
<script>
document.getElementById('year').textContent = new Date().getFullYear();
const grid = document.getElementById('grid');
const viewButtons = document.querySelectorAll('.view-btn');
const sortSelect = document.getElementById('sortSelect');
const pageSizeInput = document.getElementById('pageSizeInput');
const modal = document.getElementById('modal');
const modalMedia = document.getElementById('modalMedia');
const modalInfo = document.getElementById('modalInfo');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const pageInfo = document.getElementById('pageInfo');
let images = [],
filtered = [];
let currentSize = localStorage.getItem('nomadImagesSize') || 'medium';
let currentPage = 1,
pageSize = parseInt(pageSizeInput.value, 10);
let wasDragging = false;
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024,
sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function setViewSize(size) {
currentSize = size;
viewButtons.forEach((btn) =>
btn.classList.toggle('active', btn.dataset.size === size)
);
grid.className = `media-grid ${size}`;
localStorage.setItem('nomadImagesSize', size);
}
viewButtons.forEach((btn) =>
btn.addEventListener('click', () => setViewSize(btn.dataset.size))
);
setViewSize(currentSize);
async function generateVideoThumbnail(srcPath) {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.src = `/media?file=${encodeURIComponent(srcPath)}`;
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
video.addEventListener('loadeddata', () => {
// seek to 0.1s to get a real frame (hopeful its not just black lol)
video.currentTime = 0.1;
});
video.addEventListener('seeked', () => {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
resolve(canvas.toDataURL('image/jpeg'));
});
video.addEventListener('error', (e) => reject(e));
});
}
sortSelect.addEventListener('change', () => {
currentPage = 1;
applySort();
});
pageSizeInput.addEventListener('change', () => {
pageSize = parseInt(pageSizeInput.value, 10) || 1;
currentPage = 1;
renderPage();
});
prevBtn.addEventListener('click', () => changePage(-1));
nextBtn.addEventListener('click', () => changePage(1));
async function init() {
try {
const res = await fetch('/media.json');
const json = await res.json();
images = (json.gallery || []).map((i) => ({
name: i.name,
path: '/' + i.file.replace(/^\/?Gallery\/+/, 'Gallery/'),
type: ['mp4', 'webm', 'ogg'].includes(
(i.file.split('.').pop() || '').toLowerCase()
)
? 'video'
: 'image',
size: 0,
}));
await Promise.all(
filtered.map(async (f) => {
try {
const response = await fetch(f.path, {
method: 'GET',
headers: { 'Range': 'bytes=0-0' },
});
const len = response.headers.get('Content-Range')?.split('/')[1];
f.size = parseInt(len) || 0;
} catch {
f.size = 0;
}
if (f._infoEl) {
f._infoEl.textContent = `${f.type.toUpperCase()} • ${formatBytes(f.size)}`;
}
})
);
applySort();
} catch (e) {
console.error('Media load error:', e);
grid.innerHTML =
'<p style="grid-column:1/-1;text-align:center;color:var(--primary-dark);">Unable to load images.</p>';
}
}
function applySort() {
const mode = sortSelect.value;
filtered = [...images];
if (mode.startsWith('name'))
filtered.sort((a, b) =>
mode === 'nameDesc'
? b.name.localeCompare(a.name)
: a.name.localeCompare(b.name)
);
else if (mode.startsWith('type'))
filtered.sort((a, b) =>
mode === 'typeDesc'
? b.type.localeCompare(a.type)
: a.type.localeCompare(b.type)
);
else if (mode.startsWith('size'))
filtered.sort((a, b) =>
mode === 'sizeDesc' ? b.size - a.size : a.size - b.size
);
renderPage();
}
function renderPage() {
const start = (currentPage - 1) * pageSize;
const end = start + pageSize;
const pageItems = filtered.slice(start, end);
grid.innerHTML = '';
pageItems.forEach((item) => {
const isVideo = item.type === 'video';
const card = document.createElement('div');
card.className = 'media-card';
if (isVideo) card.classList.add('video');
const el = document.createElement('img');
el.className = 'media-thumb';
el.alt = item.name;
el.loading = 'lazy';
if (isVideo) {
el.src = '';
generateVideoThumbnail(item.path)
.then((dataUrl) => {
el.src = dataUrl;
})
.catch(() => {
el.src = '/video-error.png';
});
} else {
el.src = item.path;
}
card.appendChild(el);
if (isVideo) {
const playOverlay = document.createElement('div');
playOverlay.className = 'play-overlay';
playOverlay.innerHTML = '▶';
card.appendChild(playOverlay);
}
card.addEventListener('click', () => openModal(item));
grid.appendChild(card);
});
const totalPages = Math.ceil(filtered.length / pageSize) || 1;
pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage === totalPages;
}
function changePage(delta) {
currentPage += delta;
renderPage();
}
function openModal(item) {
modalMedia.innerHTML = '';
modalInfo.innerHTML = '';
let mediaEl;
if (item.type === 'video') {
mediaEl = document.createElement('video');
mediaEl.src = `/media?file=${encodeURIComponent(item.path)}`;
generateVideoThumbnail(item.path)
.then((posterUrl) => {
mediaEl.poster = posterUrl;
})
.catch(() => {
/* leave no poster */
});
mediaEl.controls = true;
mediaEl.autoplay = false;
mediaEl.loop = false;
mediaEl.preload = 'metadata';
mediaEl.setAttribute('playsinline', '');
mediaEl.style.maxWidth = '100%';
modalMedia.appendChild(mediaEl);
} else {
mediaEl = document.createElement('img');
mediaEl.src = item.path;
mediaEl.alt = item.name;
modalMedia.appendChild(mediaEl);
}
modalInfo.innerHTML = [
`Name: ${item.name}`,
`Type: ${item.type}`,
`Size: ${formatBytes(item.size)}`,
]
.map((l) => `<p>${l}</p>`)
.join('');
modal.style.display = 'flex';
// Setup zoom & drag on mediaEl
mediaEl.style.transformOrigin = 'center center';
mediaEl.style.cursor = 'zoom-in';
mediaEl.style.transition = 'none';
mediaEl.style.userSelect = 'none';
mediaEl.style.position = 'relative';
mediaEl.setAttribute('draggable', 'false');
let zoom = 1,
offsetX = 0,
offsetY = 0,
dragStartX = 0,
dragStartY = 0,
isDragging = false,
dragMoved = false;
function updateTransform() {
mediaEl.style.transform = `scale(${zoom}) translate(${offsetX}px, ${offsetY}px)`;
mediaEl.style.cursor = zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'zoom-in';
}
mediaEl.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = mediaEl.getBoundingClientRect();
const mx = e.clientX - rect.left,
my = e.clientY - rect.top;
const oldZoom = zoom;
zoom = Math.min(Math.max(1, zoom + Math.sign(e.deltaY) * -0.2), 5);
const factor = zoom / oldZoom;
offsetX -= (mx / oldZoom) * (factor - 1);
offsetY -= (my / oldZoom) * (factor - 1);
updateTransform();
});
mediaEl.addEventListener('click', (e) => {
if (dragMoved) {
dragMoved = false;
return;
}
const rect = mediaEl.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
if (zoom === 1) {
const zoomFactor = 2;
const cx = rect.width / 2;
const cy = rect.height / 2;
zoom = zoomFactor;
offsetX = (cx - mx) / zoom;
offsetY = (cy - my) / zoom;
} else {
zoom = 1;
offsetX = 0;
offsetY = 0;
}
updateTransform();
});
mediaEl.addEventListener('mousedown', (e) => {
if (zoom === 1) return;
isDragging = true;
dragMoved = false;
dragStartX = e.clientX;
dragStartY = e.clientY;
mediaEl.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = (e.clientX - dragStartX) / zoom;
const dy = (e.clientY - dragStartY) / zoom;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
dragMoved = true;
wasDragging = true;
}
offsetX += dx;
offsetY += dy;
dragStartX = e.clientX;
dragStartY = e.clientY;
updateTransform();
});
window.addEventListener('mouseup', (e) => {
if (!isDragging) return;
isDragging = false;
mediaEl.style.cursor = 'grab';
setTimeout(() => (wasDragging = false), 100);
});
updateTransform();
}
function closeModal(evt) {
if (wasDragging) {
wasDragging = false;
return;
}
if (evt.target === modal || evt.target.classList.contains('modal-close')) {
const vid = modalMedia.querySelector('video');
if (vid) {
vid.pause();
vid.removeAttribute('src');
vid.load();
}
modal.style.display = 'none';
}
}
init();
// Upload handling
const btnUpload = document.getElementById('btn-upload');
const uploadInput = document.getElementById('upload-input');
const uploadProgress = document.getElementById('upload-progress');
const uploadBar = document.getElementById('upload-bar');
const uploadPercent = document.getElementById('upload-percent');
btnUpload.addEventListener('click', () => uploadInput.click());
uploadInput.addEventListener('change', async function () {
const files = Array.from(this.files);
if (!files.length) return;
for (let file of files) {
await uploadFile(file);
}
// Refresh gallery after uploads
init();
});
async function uploadFile(file) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
// Pass dir param as required by backend
formData.append('dir', '/Gallery');
formData.append('file', file);
xhr.open('POST', '/upload', true);
xhr.upload.addEventListener('loadstart', () => {
uploadProgress.style.display = 'block';
uploadPercent.style.display = 'inline';
uploadBar.style.width = '0%';
uploadPercent.textContent = '0%';
});
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
uploadBar.style.width = percent + '%';
uploadPercent.textContent = percent + '%';
}
});
xhr.onload = () => {
if (xhr.status === 200) {
console.log('[Upload] Complete:', file.name);
} else {
console.error('[Upload] Failed:', file.name, xhr.responseText);
}
resolve();
};
xhr.onerror = () => {
console.error('[Upload] Error:', file.name);
reject();
};
xhr.send(formData);
});
}
</script>
</body>
</html> |
Beta Was this translation helpful? Give feedback.
-
Thanks for doing this. The gallery page does not refresh upon upload completion. It just sits there with the status bar at 100%. |
Beta Was this translation helpful? Give feedback.
-
Ok cool, I saw your note about patching gallery.html. As for the media.json file, I was thinking that maybe the way to go would be to break it up into a series of smaller json files (files.json, gallery.json, movies.json, etc) and either store them in ./config or the relevant media directory. on the back end, the generate json stuff could be converted to a switch statement that switches on the directory name (files,gallery, shows, movies, etc) and performs operations on the related json file. You already send directory name as part of the upload form data, once upload is done, it can then call generate_json(dir). Just a thought. |
Beta Was this translation helpful? Give feedback.
-
Here's the one line change to have gallery refresh properly after upload: // Force full reload to ensure new files appear |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
When do you think your next update will be? I want to send an offline hub to burning man, so Im trying to decide do I go with the current release or wait for the next one if its going to be released in a couple of days. Thanks!
Beta Was this translation helpful? Give feedback.
All reactions