feat(upload): фото и файлы отдельными путями на Android
This commit is contained in:
1 parent
19547d1797
commit
21f8fbb6a4
6 files changed
+128
-6
No files matched your search
@@ -160,3 +160,7 @@ Preland всегда формирует полный resolved-набор сво
|
||||
**Откат вручную.** Перед заменой файлов — копия изменяемых файлов (`index.php`, `components/*`) с хостинга; вернуть их той же
|
||||
загрузкой. Новый преленд требует на сайте `/api/images/public/claim` (api с 2026-10-05): со старым api переход
|
||||
по ключу отдаст 404.
|
||||
|
||||
## Android: фото и файлы (2026-10-19)
|
||||
Основная кнопка выбирает image/*; отдельная ссылка «Выбрать файлы (PDF и др.)» открывает общий picker без ограничения image/*. PDF в подписи скрывается по runtime API-policy. iOS/iPad/Desktop сохраняют прежний общий выбор. +много сохраняется в одной компактной mobile-строке. components/browser-upload.php и CSS upload-picker-links.php — лёгкие inline-копии соответствующих компонентов API: отдельный PHP-хост не использует Yii AssetManager, новый внешний запрос не добавляется. При изменении общего контракта синхронизировать обе копии; API mobile-picker.cjs покрывает обе реальные страницы. Android byte-copy FileProvider сохраняет исходные байты/имя/MIME и ограничен max_file_size_bytes; до ответа policy используется консервативный15MB предел копирования. UTM/client_key/claim/config.json не меняются.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php /* Лёгкая inline-копия API browser-upload: единый контракт, без внешнего запроса. */ ?>
|
||||
<script>
|
||||
(() => {
|
||||
if (window.ZoomzillaUpload) return;
|
||||
const android = /Android/i.test(navigator.userAgent);
|
||||
let uploadQueue = Promise.resolve();
|
||||
window.ZoomzillaUpload = {
|
||||
bindPicker(form, mainInput, fullAccept, onFiles) {
|
||||
const mobile = android || /iPhone|iPad|iPod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
if (mobile) form.dataset.mobileUpload = '1';
|
||||
const sync = () => {
|
||||
const input = typeof mainInput === 'function' ? mainInput() : mainInput;
|
||||
if (input) input.accept = android ? 'image/*' : (typeof fullAccept === 'function' ? fullAccept() : fullAccept);
|
||||
};
|
||||
sync();
|
||||
// Dropzone пересоздаёт input после каждого выбора: обновляем до native click.
|
||||
form.addEventListener('click', sync, true);
|
||||
if (!android) return;
|
||||
const link = form.querySelector('[data-files-picker]');
|
||||
const other = form.querySelector('[data-other-files]');
|
||||
if (!link || !other) return;
|
||||
form.querySelector('[data-picker-or]').hidden = false;
|
||||
link.hidden = false;
|
||||
const label = form.querySelector('[data-upload-label]');
|
||||
if (label) {
|
||||
label.textContent = form.dataset.photoLabel;
|
||||
label.dataset.pickerLabel = form.dataset.photoLabel;
|
||||
}
|
||||
other.addEventListener('click', event => event.stopPropagation());
|
||||
link.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (form.querySelector('.dropzone-form-button')?.disabled) return;
|
||||
other.value = '';
|
||||
other.click();
|
||||
});
|
||||
other.addEventListener('change', event => {
|
||||
event.stopPropagation();
|
||||
const files = Array.from(other.files || []);
|
||||
other.value = '';
|
||||
if (files.length) onFiles(files);
|
||||
});
|
||||
},
|
||||
run(file, maxBytes, send) {
|
||||
const upload = () => this.prepare(file, maxBytes).then(send);
|
||||
if (!android) return upload();
|
||||
// В кабинете до10 файлов: удерживаем в памяти только одну копию.
|
||||
const pending = uploadQueue.then(upload);
|
||||
uploadQueue = pending.catch(() => {});
|
||||
return pending;
|
||||
},
|
||||
prepare(file, maxBytes) {
|
||||
// FileProvider может отдавать несовпадающие lastModified для сетевого
|
||||
// процесса Chromium. Blob из прочитанных байтов от него не зависит.
|
||||
if (!android || !file.size || file.size > maxBytes) return Promise.resolve(file);
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
const timer = window.setTimeout(() => reader.abort(), 30000);
|
||||
const failed = error => {
|
||||
window.clearTimeout(timer);
|
||||
window.UploadDiagnostics?.record('buffer_error', { error_name: error.name, message: error.message });
|
||||
reject(error);
|
||||
};
|
||||
window.UploadDiagnostics?.record('buffer_start', { size: file.size });
|
||||
reader.onload = () => {
|
||||
window.clearTimeout(timer);
|
||||
if (reader.result.byteLength !== file.size) {
|
||||
failed(new Error('Размер файла изменился при чтении.'));
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([reader.result], { type: file.type });
|
||||
window.UploadDiagnostics?.record('buffer_ok', { size: blob.size, mime: blob.type });
|
||||
resolve(blob);
|
||||
};
|
||||
reader.onerror = () => failed(reader.error || new Error('Не удалось прочитать файл.'));
|
||||
reader.onabort = () => failed(new Error('Чтение файла прервано.'));
|
||||
try { reader.readAsArrayBuffer(file.slice(0, file.size, file.type)); } catch (error) { failed(error); }
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
+15
-5
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
require __DIR__ . '/browser-upload.php';
|
||||
|
||||
$errorMessage = Yii::t('app', 'Возникла ошибка');
|
||||
$utmJson = json_encode($UTM ?? [], JSON_UNESCAPED_UNICODE);
|
||||
@@ -11,7 +12,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
||||
|
||||
?>
|
||||
|
||||
<form action="<?= htmlspecialchars($createApiUrl, ENT_QUOTES, 'UTF-8') ?>" class="dropzone-form gradient-dashed-border" id="dropzone-form" method="POST" style="background: transparent; border: unset; display: <?= ($show ?? true) ? '' : 'none'; ?>; cursor: pointer;">
|
||||
<form action="<?= htmlspecialchars($createApiUrl, ENT_QUOTES, 'UTF-8') ?>" class="dropzone-form gradient-dashed-border" id="dropzone-form" data-photo-label="<?= htmlspecialchars(Yii::t('app', 'Выбрать фото'), ENT_QUOTES, 'UTF-8') ?>" method="POST" style="background: transparent; border: unset; display: <?= ($show ?? true) ? '' : 'none'; ?>; cursor: pointer;">
|
||||
|
||||
<div class="dropzone-form-inner">
|
||||
<div class="dropzone-form-icon">
|
||||
@@ -26,7 +27,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
||||
<div class="dropzone-form-format-item">HEIC</div>
|
||||
<div class="dropzone-form-format-item">WebP</div>
|
||||
<div class="dropzone-form-format-item">GIF</div>
|
||||
<div class="dropzone-form-format-item">PDF</div>
|
||||
<div class="dropzone-form-format-item" data-format-optional>PDF</div>
|
||||
<div class="dropzone-form-format-item">+много</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,6 +38,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
||||
<path d="M13.3333 14.6667C15.6345 14.6667 17.5 12.8012 17.5 10.5C17.5 8.43079 15.9917 6.71387 14.0145 6.38874C14.0113 6.38821 14.0086 6.38583 14.0078 6.38267C13.4802 4.43373 11.6992 3 9.58333 3C7.05203 3 5 5.05203 5 7.58333C5 7.75477 5.00941 7.924 5.02775 8.09055C5.0282 8.0947 5.0255 8.09856 5.02145 8.09957C3.57289 8.4621 2.5 9.77242 2.5 11.3333C2.5 13.1743 3.99238 14.6667 5.83333 14.6667H6.66667M10 8.83726V18M10 8.83726L7.5 11.3333M10 8.83726L12.5 11.3333" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<?php require __DIR__ . '/upload-picker-links.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,14 +80,16 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
||||
// отправляется в content-based Tour gate, где и показывается controlled error.
|
||||
let pickerAccept = <?= json_encode($basePickerAccept, JSON_UNESCAPED_UNICODE) ?>;
|
||||
let maxUploadBytes = 0;
|
||||
window.ZoomzillaUpload.bindPicker(formEl, fileInput, () => pickerAccept, files => handleSelectedFile(files[0]));
|
||||
if (uploadPolicyApiUrl && window.fetch) {
|
||||
fetch(uploadPolicyApiUrl, { credentials: 'omit', cache: 'no-store' })
|
||||
.then(response => response.ok ? response.json() : null)
|
||||
.then(policy => {
|
||||
if (typeof policy?.picker_accept === 'string' && policy.picker_accept !== '') {
|
||||
pickerAccept = policy.picker_accept;
|
||||
fileInput.accept = pickerAccept;
|
||||
fileInput.accept = /Android/i.test(navigator.userAgent) ? 'image/*' : pickerAccept;
|
||||
}
|
||||
if (policy?.pdf_observation_enabled === false) formEl.querySelector('[data-files-picker]').textContent = <?= json_encode(Yii::t('app', 'Выбрать файлы (другие форматы)'), JSON_UNESCAPED_UNICODE) ?>;
|
||||
if (Number.isSafeInteger(policy?.max_file_size_bytes) && policy.max_file_size_bytes > 0) {
|
||||
maxUploadBytes = policy.max_file_size_bytes;
|
||||
}
|
||||
@@ -124,7 +128,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
||||
uploadPending = false;
|
||||
if (uploadDotsTimer !== null) window.clearInterval(uploadDotsTimer);
|
||||
uploadDotsTimer = null;
|
||||
if (uploadButtonLabel) uploadButtonLabel.textContent = uploadButtonDefaultLabel;
|
||||
if (uploadButtonLabel) uploadButtonLabel.textContent = uploadButtonLabel.dataset.pickerLabel || uploadButtonDefaultLabel;
|
||||
if (uploadButton) {
|
||||
uploadButton.disabled = false;
|
||||
uploadButton.removeAttribute('aria-busy');
|
||||
@@ -217,12 +221,18 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
||||
}
|
||||
|
||||
function uploadFile(file) {
|
||||
// Тот же обход нестабильного Android FileProvider, что на основном сайте.
|
||||
window.ZoomzillaUpload.prepare(file, maxUploadBytes || 15 * 1024 * 1024)
|
||||
.then(bytes => uploadPreparedFile(file, bytes))
|
||||
.catch(() => showUploadError(genericErrorMsg));
|
||||
}
|
||||
function uploadPreparedFile(file, bytes) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const formData = new FormData();
|
||||
// Ключ загрузки: по нему сайт передаёт её в сессию посетителя (/api/images/public/claim, годен 30 мин).
|
||||
// randomUUID есть в HTTPS-контексте; где его нет (старый Safari), есть getRandomValues.
|
||||
const clientKey = createClientKey();
|
||||
formData.append('file', file);
|
||||
formData.append('file', bytes, file.name || 'upload');
|
||||
formData.append('client_key', clientKey);
|
||||
|
||||
const actionUrl = createApiUrl || formEl.getAttribute('action') || '/api/images/public/create';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php /* CSS синхронна API upload-picker-links; отдельный хост без Yii assets. */ ?>
|
||||
<style>
|
||||
[data-picker-or][hidden], [data-files-picker][hidden] { display: none !important; }
|
||||
#dropzone-form[data-mobile-upload] { padding: 16px; }
|
||||
#dropzone-form[data-mobile-upload] .dropzone-form-icon img { width: 44px; height: 44px; }
|
||||
#dropzone-form[data-mobile-upload] .dropzone-form-text { margin-top: 8px; font-size: 16px; line-height: 24px; }
|
||||
#dropzone-form[data-mobile-upload] .dropzone-form-format { margin-top: 10px; flex-direction: column; gap: 6px; }
|
||||
#dropzone-form[data-mobile-upload] .dropzone-form-format-label { font-size: 11px; line-height: 16px; }
|
||||
#dropzone-form[data-mobile-upload] .dropzone-form-format-list { flex-wrap: nowrap; gap: 4px; justify-content: center; }
|
||||
#dropzone-form[data-mobile-upload] .dropzone-form-format-item { padding: 3px 5px; border-radius: 6px; font-size: 11px; line-height: 15px; white-space: nowrap; }
|
||||
#dropzone-form[data-mobile-upload] .dropzone-form-action { margin-top: 12px; display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
||||
#dropzone-form[data-mobile-upload] .dropzone-form-button { width: 100%; }
|
||||
#dropzone-form[data-mobile-upload] [data-picker-or] { display: block; margin: 0; font-size: 10px; line-height: 14px; color: #979aa0; }
|
||||
#dropzone-form[data-mobile-upload] [data-files-picker] { display: block; position: relative; margin: -2px 0 0; font-size: 12px; line-height: 18px; text-decoration: underline; text-underline-offset: 3px; pointer-events: auto; }
|
||||
#dropzone-form.theme-dark [data-files-picker], .theme-dark #dropzone-form [data-files-picker] { color: #b6caff; }
|
||||
/* Расширяем область нажатия вниз, не меняя утверждённый видимый зазор4px. */
|
||||
#dropzone-form [data-files-picker]::after { content: ''; position: absolute; inset: 0 -8px -14px; }
|
||||
#dropzone-form[data-mobile-upload] .dropzone-form-format-item[data-format-optional] { display: none; }
|
||||
</style>
|
||||
<span data-picker-or hidden><?= Yii::t('app', 'или') ?></span>
|
||||
<a href="#" data-files-picker hidden><?= Yii::t('app', 'Выбрать файлы (PDF и др.)') ?></a>
|
||||
<input type="file" data-other-files style="display:none">
|
||||
@@ -137,6 +137,10 @@ return [
|
||||
'Загрузите изображение' => 'Upload an image',
|
||||
'Загрузить' => 'Upload',
|
||||
'Загрузить изображение' => 'Upload Image',
|
||||
'Выбрать фото' => 'Choose photo',
|
||||
'или' => 'or',
|
||||
'Выбрать файлы (PDF и др.)' => 'Choose files (PDF, etc.)',
|
||||
'Выбрать файлы (другие форматы)' => 'Choose files (other formats)',
|
||||
'Загрузка тарифов...' => 'Loading plans...',
|
||||
'Загрузка...' => 'Loading...',
|
||||
'Запись в историю добавлена!' => 'History entry added',
|
||||
|
||||
@@ -11,7 +11,7 @@ if ($form === false) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
preg_match_all('/<div class="dropzone-form-format-item">([^<]+)<\\/div>/', $form, $formatMatches);
|
||||
preg_match_all('/<div class="dropzone-form-format-item"[^>]*>([^<]+)<\\/div>/', $form, $formatMatches);
|
||||
$visibleFormats = ['JPG', 'PNG', 'HEIC', 'WebP', 'GIF', 'PDF', '+много'];
|
||||
if ($formatMatches[1] !== $visibleFormats) {
|
||||
fwrite(STDERR, "FAIL: Preland не показывает утверждённый компактный список форматов в нужном порядке.\n");
|
||||
|
||||
Reference in new issue
Block a user