feat: env-driven upload limits + request timeout, inline video playback, range requests

- Add UPLOAD_FILE_LIMIT_MB/UPLOAD_TOTAL_LIMIT_MB env (defaults 50/200), compute request timeout from total limit or UPLOAD_REQUEST_TIMEOUT_MS
- Expose upload limits via /api/public-settings and sync in frontend (remove hardcoded 50MB assumption)
- Add byte-range support in storage (getRange/streamRangeTo) and serve Content-Range/Accept-Ranges for S3/local
- Implement inline playable video delivery for browser formats (mp4/m4v/webm/ogv) with ?play=1, range requests, proper 206/416
- Add video modal in journal UI with player and download fallback
- Update docs (AGENTS.md/PRD.md/README.md), styles for video modal, add instructions/TODO.md and screenshots
- Extend MIME types for media
This commit is contained in:
dev
2026-10-03 12:00:48 +03:00
parent 104bdc4f49
commit 70b0c7ae9b
71 changed files with 307 additions and 6 deletions
+9
View File
@@ -215,6 +215,15 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none;align-items:center;justify-content:center}
.modal-overlay.open{display:flex}
.modal-overlay > img{max-width:90%;max-height:90dvh;border-radius:var(--radius)}
#videoModal{background:rgba(0,0,0,.85)}
.video-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:16px;width:min(1000px,94vw);max-height:94dvh;display:flex;flex-direction:column;gap:12px}
.video-head{display:flex;align-items:center;gap:12px;font-size:.85rem;color:var(--text)}
.video-head > span:first-child{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.video-dl{background:none;border:1px solid var(--border);border-radius:6px;padding:3px 10px;color:var(--muted);text-decoration:none;font-size:.75rem}
.video-dl:hover{color:var(--accent);border-color:var(--accent)}
.video-close{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.4rem;line-height:1;padding:0 4px}
.video-close:hover{color:var(--text)}
#videoPlayer{width:100%;max-height:78dvh;background:#000;border-radius:var(--radius);display:block}
.edit-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px;max-width:420px;width:90%;display:flex;flex-direction:column;gap:16px}
.edit-modal h3{font-size:1.1rem;font-weight:600}
.edit-modal label{font-size:.8rem;color:var(--muted);margin-bottom:4px}
+11
View File
@@ -66,6 +66,17 @@
<img id="imgModalSrc">
</div>
<div class="modal-overlay" id="videoModal">
<div class="video-modal">
<div class="video-head">
<span id="videoTitle"></span>
<a class="video-dl" id="videoDownload" download title="Скачать файл">Скачать</a>
<button type="button" class="video-close" id="videoClose" title="Закрыть">&times;</button>
</div>
<video id="videoPlayer" controls playsinline preload="metadata"></video>
</div>
</div>
<div class="modal-overlay" id="editModal">
<div class="edit-modal" style="max-width:720px">
<h3>Редактирование записи</h3>
+60 -3
View File
@@ -34,6 +34,14 @@ function isImageFile(name) {
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
}
function isPlayableVideoFile(name) {
return /\.(mp4|m4v|webm|ogv)$/i.test(name || '');
}
function isVideoFile(name) {
return /\.(mp4|m4v|webm|ogv|mov|mkv|avi|mpeg|mpg|3gp|ts)$/i.test(name || '');
}
function mainPhotoPath(e) {
if (e.photo_path) return e.photo_path;
if (e.photos && e.photos.length) return e.photos[0].photo_path;
@@ -43,9 +51,16 @@ function mainPhotoPath(e) {
function filesHTML(files) {
if (!files || !files.length) return '';
return `<div class="entry-files">${files.map(f => {
const link = isImageFile(f.name)
? `<a class="f-link" href="#" data-img="${API}/api/files/${f.token}" title="Открыть">${esc(f.name)}</a>`
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`;
const url = `${API}/api/files/${f.token}`;
let link;
if (isImageFile(f.name)) {
link = `<a class="f-link" href="#" data-img="${url}" title="Открыть">${esc(f.name)}</a>`;
} else if (isPlayableVideoFile(f.name)) {
link = `<a class="f-link" href="${url}?play=1" data-video="${url}?play=1" data-video-name="${esc(f.name)}" data-video-token="${f.token}" title="Смотреть">${esc(f.name)}</a>`;
} else {
const note = isVideoFile(f.name) ? 'Скачать (браузер не умеет играть этот формат)' : 'Скачать';
link = `<a class="f-link" href="${url}" download title="${note}">${esc(f.name)}</a>`;
}
return `<span class="f-item">${link}<button class="f-detach" data-detach="${f.id}" title="Открепить файл">✕</button></span>`;
}).join('')}</div>`;
}
@@ -1589,6 +1604,48 @@ document.getElementById('aiCorrectModal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeAiCorrect();
});
function openVideo(url, name, token) {
const modal = document.getElementById('videoModal');
const player = document.getElementById('videoPlayer');
const title = document.getElementById('videoTitle');
const dl = document.getElementById('videoDownload');
if (!modal || !player) return;
player.src = url;
player.currentTime = 0;
title.textContent = name || 'Видео';
dl.href = `${API}/api/files/${token}`;
dl.setAttribute('download', '');
modal.classList.add('open');
const play = player.play();
if (play && typeof play.catch === 'function') play.catch(() => {});
}
function closeVideo() {
const modal = document.getElementById('videoModal');
const player = document.getElementById('videoPlayer');
if (!modal || !player) return;
try { player.pause(); } catch {}
player.removeAttribute('src');
player.load();
modal.classList.remove('open');
}
document.getElementById('videoModal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeVideo();
});
document.getElementById('videoClose').addEventListener('click', closeVideo);
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (document.getElementById('videoModal').classList.contains('open')) closeVideo();
});
document.addEventListener('click', e => {
const v = e.target.closest('[data-video]');
if (!v) return;
e.preventDefault();
openVideo(v.dataset.video, v.dataset.videoName, v.dataset.videoToken);
});
document.getElementById('exportCsvBtn').addEventListener('click', exportCSV);
document.getElementById('createLinkBtn').addEventListener('click', openLinkCreate);
document.querySelectorAll('#viewToggle button').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));