feat(upload): фото и файлы отдельными путями на Android

This commit is contained in:
Panfilov Konstantin committed 2026-10-19 12:20:22 +03:00
1 parent 19547d1797
commit 21f8fbb6a4
6 files changed
+128 -6

No files matched your search

+4
View File
@@ -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 не меняются.
+82
View File
@@ -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
View File
@@ -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';
+22
View File
@@ -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">
+4
View File
@@ -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',
+1 -1
View File
@@ -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");