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:
@@ -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}
|
||||
|
||||
@@ -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="Закрыть">×</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
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user