Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
144dda6d88 | ||
|
|
0c9a406d68 | ||
|
|
75ccf14889 | ||
|
|
6a2532cb87 | ||
|
|
4c5d3de7bc | ||
|
|
5afa71ec84 | ||
|
|
d70d396670 | ||
|
|
1a20ce17d5 | ||
|
|
f2e6877013 | ||
|
|
7eaf1c12b0 | ||
|
|
c30e3239a0 | ||
|
|
d2637e8f61 | ||
|
|
0f06a18622 | ||
|
|
0274d5d921 | ||
|
|
dcb914a429 | ||
|
|
ab84dc1629 | ||
|
|
b4dedcfea5 | ||
|
|
2180d9e37b |
No files matched your search
@@ -1,118 +1,13 @@
|
||||
name: deploy-preland
|
||||
|
||||
# После коммита в main сохраняется legacy-выпуск на Beget. Новые домены ZoomPixel
|
||||
# доставляются отдельными SFTP job: staging — только на stage, main — только на production.
|
||||
# Цель — домен со своим хостингом (список — matrix ниже): реквизиты — секреты репозитория <prefix>_FTP_HOST,
|
||||
# <prefix>_FTP_USER, <prefix>_FTP_PASS (порт 21). Цели независимы: сбой одного хостинга остальные не останавливает.
|
||||
# Заливаются файлы репозитория (git ls-files), кроме служебных (.gitea/, README.md, AGENTS.md, CLAUDE.md, .gitignore) и тех, что живут
|
||||
# только на хостинге: requisites.html (реквизиты заполняют на месте — пустой файл из git их бы затёр).
|
||||
# Рабочий config.json настраивается владельцем на каждом хостинге. Деплой не
|
||||
# перезаписывает его: он добавляет только отсутствующие обязательные UTM-ключи.
|
||||
# Единственное контролируемое удаление — завершённая миграция legacy images/demo* после
|
||||
# smoke новых корневых наборов /1, /2. Прочие файлы на хостинге не удаляются.
|
||||
|
||||
# Решение владельца 19.10.2026, preland#4: только stage.
|
||||
# Production выкаты временно отключены; Барбариска больше не используется.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- staging
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: deploy ${{ matrix.name }}
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# name — имя цели, url — адрес преленда (для проверки), prefix — префикс секретов <prefix>_FTP_*,
|
||||
# dir — каталог сайта на FTP ("" — корень FTP, как на beget). Новый домен — новая запись и три секрета;
|
||||
# requisites.html создают на хостинге цели. Рабочий config.json приходит из Git.
|
||||
- name: barbariskatest
|
||||
url: https://preland.anton.barbariskatest.ru
|
||||
prefix: PRELAND
|
||||
dir: ""
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
env:
|
||||
PRELAND_URL: ${{ matrix.url }}
|
||||
REMOTE_DIR: ${{ matrix.dir }}
|
||||
|
||||
steps:
|
||||
- name: Install tools (Alpine)
|
||||
run: apk add --no-cache git curl php83-cli
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Preserve host config and add focus if absent
|
||||
env:
|
||||
FTP_HOST: ${{ secrets[format('{0}_FTP_HOST', matrix.prefix)] }}
|
||||
FTP_USER: ${{ secrets[format('{0}_FTP_USER', matrix.prefix)] }}
|
||||
FTP_PASS: ${{ secrets[format('{0}_FTP_PASS', matrix.prefix)] }}
|
||||
run: |
|
||||
set -eu
|
||||
[ -n "$FTP_HOST" ] && [ -n "$FTP_USER" ] && [ -n "$FTP_PASS" ] || { echo "нет секретов ${{ matrix.prefix }}_FTP_*"; exit 1; }
|
||||
dir=$(printf '%s' "$REMOTE_DIR" | sed 's#^/*##; s#/*$##')
|
||||
umask 077
|
||||
netrc=$(mktemp); config=$(mktemp)
|
||||
trap 'rm -f "$netrc" "$config"' EXIT
|
||||
printf 'machine %s login %s password %s\n' "$FTP_HOST" "$FTP_USER" "$FTP_PASS" > "$netrc"
|
||||
curl -sS --ssl-reqd --netrc-file "$netrc" --fail --retry 3 \
|
||||
"ftp://$FTP_HOST/${dir:+$dir/}config.json" -o "$config"
|
||||
changed=0
|
||||
php bin/ensure-focus-config.php "$config" || changed=$?
|
||||
case "$changed" in
|
||||
0) ;;
|
||||
10) curl -sS --ssl-reqd --netrc-file "$netrc" --ftp-create-dirs --retry 3 --fail \
|
||||
-T "$config" "ftp://$FTP_HOST/${dir:+$dir/}config.json" ;;
|
||||
*) exit "$changed" ;;
|
||||
esac
|
||||
|
||||
- name: Upload over FTP (TLS)
|
||||
env:
|
||||
FTP_HOST: ${{ secrets[format('{0}_FTP_HOST', matrix.prefix)] }}
|
||||
FTP_USER: ${{ secrets[format('{0}_FTP_USER', matrix.prefix)] }}
|
||||
FTP_PASS: ${{ secrets[format('{0}_FTP_PASS', matrix.prefix)] }}
|
||||
run: |
|
||||
set -eu
|
||||
[ -n "$FTP_HOST" ] && [ -n "$FTP_USER" ] && [ -n "$FTP_PASS" ] || { echo "нет секретов ${{ matrix.prefix }}_FTP_*"; exit 1; }
|
||||
dir=$(printf '%s' "$REMOTE_DIR" | sed 's#^/*##; s#/*$##')
|
||||
case "$dir" in *[!A-Za-z0-9._/-]*) echo "недопустимый dir: $dir"; exit 1 ;; esac
|
||||
umask 077
|
||||
netrc=$(mktemp); list=$(mktemp)
|
||||
trap 'rm -f "$netrc" "$list"' EXIT
|
||||
printf 'machine %s login %s password %s\n' "$FTP_HOST" "$FTP_USER" "$FTP_PASS" > "$netrc"
|
||||
git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$|config\.json$)' > files.txt
|
||||
# имя уходит в адрес ftp:// как есть — пробелы и прочее экранирование не поддерживаем
|
||||
if grep -q '[^A-Za-z0-9._/-]' files.txt; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' files.txt; exit 1; fi
|
||||
# один curl на все файлы — одно соединение FTP+TLS вместо соединения на файл
|
||||
while read -r f; do printf 'upload-file = "%s"\nurl = "ftp://%s/%s%s"\n' "$f" "$FTP_HOST" "${dir:+$dir/}" "$f"; done < files.txt > "$list"
|
||||
curl -sS --ssl-reqd --netrc-file "$netrc" --ftp-create-dirs --retry 3 --fail-early -K "$list"
|
||||
echo "залито файлов: $(wc -l < files.txt)"
|
||||
|
||||
- name: Check the page
|
||||
run: |
|
||||
set -eu
|
||||
page=$(curl -fsS --max-time 30 "$PRELAND_URL/?nc=$(date +%s)")
|
||||
api=$(printf '%s' "$page" | grep -o 'const createApiUrl = "[^"]*"' || true)
|
||||
echo "$api"
|
||||
case "$api" in
|
||||
*'\/api\/images\/public\/create'*) ;;
|
||||
*) echo "на странице нет адреса загрузки"; exit 1 ;;
|
||||
esac
|
||||
own=$(printf '%s' "$PRELAND_URL" | sed -E 's#^https?://([^/]+).*#\1#')
|
||||
case "$api" in
|
||||
*"$own"*) echo "загрузка уходит на сам преленд: на хостинге нет config.json с host"; exit 1 ;;
|
||||
esac
|
||||
printf '%s' "$page" | grep -q 'claim?key=' || { echo "переход не на claim — залит старый код?"; exit 1; }
|
||||
echo "страница в порядке"
|
||||
|
||||
deploy-zoomstage:
|
||||
name: deploy zoomstage.online
|
||||
if: github.ref == 'refs/heads/staging'
|
||||
@@ -170,7 +65,10 @@ jobs:
|
||||
dirs=$(mktemp)
|
||||
trap 'rm -f "$known_hosts" "$batch" "$files" "$dirs"' EXIT
|
||||
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
|
||||
git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$|config\.json$)' > "$files"
|
||||
# Наборы `/N/` на stage — редактируемый контент. Кодовая доставка их
|
||||
# намеренно не перезаписывает: актуальное содержимое stage фиксируют в Git
|
||||
# отдельной синхронизацией по задаче.
|
||||
git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$|config\.json$|[0-9]+/)' > "$files"
|
||||
if grep -q '[^A-Za-z0-9._/-]' "$files"; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' "$files"; exit 1; fi
|
||||
printf '%s\n' "-mkdir $REMOTE_DIR" > "$batch"
|
||||
while read -r file; do
|
||||
@@ -224,97 +122,3 @@ jobs:
|
||||
printf '%s\n' "-rmdir $REMOTE_DIR/images/$set" >> "$batch"
|
||||
done
|
||||
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" "$SFTP_USER@$SFTP_HOST" < "$batch"
|
||||
|
||||
deploy-zoompixel-production:
|
||||
name: deploy ${{ matrix.name }}
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: zoomzilla-production
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: zoompixel.ru
|
||||
url: https://zoompixel.ru
|
||||
dir: domains/zoompixel.ru
|
||||
- name: zoompixel.online
|
||||
url: https://zoompixel.online
|
||||
dir: domains/zoompixel.online
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
env:
|
||||
PRELAND_URL: ${{ matrix.url }}
|
||||
REMOTE_DIR: ${{ matrix.dir }}
|
||||
steps:
|
||||
- name: Install tools
|
||||
run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl openssh-client sshpass php-cli
|
||||
|
||||
- name: Checkout production branch
|
||||
run: git clone --depth 1 --branch main http://git.dira89kv.beget.tech:3000/anton/preland.git .
|
||||
|
||||
- name: Preserve host config and add focus if absent
|
||||
env:
|
||||
SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }}
|
||||
SFTP_USER: ${{ secrets.ZOOMPIXEL_SFTP_USER }}
|
||||
SFTP_PASS: ${{ secrets.ZOOMPIXEL_SFTP_PASS }}
|
||||
SFTP_KNOWN_HOSTS: ${{ secrets.ZOOMPIXEL_SFTP_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -eu
|
||||
[ -n "$SFTP_HOST" ] && [ -n "$SFTP_USER" ] && [ -n "$SFTP_PASS" ] && [ -n "$SFTP_KNOWN_HOSTS" ] || { echo "не заданы секреты ZOOMPIXEL_SFTP_*"; exit 1; }
|
||||
umask 077
|
||||
known_hosts=$(mktemp); batch=$(mktemp); config=$(mktemp)
|
||||
trap 'rm -f "$known_hosts" "$batch" "$config"' EXIT
|
||||
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
|
||||
printf '%s\n' "get -p $REMOTE_DIR/config.json $config" > "$batch"
|
||||
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" "$SFTP_USER@$SFTP_HOST" < "$batch"
|
||||
changed=0
|
||||
php bin/ensure-focus-config.php "$config" || changed=$?
|
||||
case "$changed" in
|
||||
0) ;;
|
||||
10) printf '%s\n' "put -p $config $REMOTE_DIR/config.json" > "$batch"
|
||||
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" "$SFTP_USER@$SFTP_HOST" < "$batch" ;;
|
||||
*) exit "$changed" ;;
|
||||
esac
|
||||
|
||||
- name: Upload over SFTP
|
||||
env:
|
||||
SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }}
|
||||
SFTP_USER: ${{ secrets.ZOOMPIXEL_SFTP_USER }}
|
||||
SFTP_PASS: ${{ secrets.ZOOMPIXEL_SFTP_PASS }}
|
||||
SFTP_KNOWN_HOSTS: ${{ secrets.ZOOMPIXEL_SFTP_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -eu
|
||||
[ -n "$SFTP_HOST" ] && [ -n "$SFTP_USER" ] && [ -n "$SFTP_PASS" ] && [ -n "$SFTP_KNOWN_HOSTS" ] || { echo "не заданы секреты ZOOMPIXEL_SFTP_*"; exit 1; }
|
||||
umask 077
|
||||
known_hosts=$(mktemp)
|
||||
batch=$(mktemp)
|
||||
files=$(mktemp)
|
||||
dirs=$(mktemp)
|
||||
trap 'rm -f "$known_hosts" "$batch" "$files" "$dirs"' EXIT
|
||||
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
|
||||
git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$|config\.json$)' > "$files"
|
||||
if grep -q '[^A-Za-z0-9._/-]' "$files"; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' "$files"; exit 1; fi
|
||||
printf '%s\n' "-mkdir $REMOTE_DIR" > "$batch"
|
||||
while read -r file; do
|
||||
case "$file" in
|
||||
*/*) dir=${file%/*} ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
while [ -n "$dir" ]; do
|
||||
printf '%s\n' "$dir" >> "$dirs"
|
||||
case "$dir" in */*) dir=${dir%/*} ;; *) break ;; esac
|
||||
done
|
||||
done < "$files"
|
||||
sort -u "$dirs" | while read -r dir; do printf '%s\n' "-mkdir $REMOTE_DIR/$dir" >> "$batch"; done
|
||||
while read -r file; do printf '%s\n' "put -p $file $REMOTE_DIR/$file" >> "$batch"; done < "$files"
|
||||
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" "$SFTP_USER@$SFTP_HOST" < "$batch"
|
||||
echo "залито файлов: $(wc -l < "$files")"
|
||||
|
||||
- name: Check the page
|
||||
run: |
|
||||
set -eu
|
||||
page=$(curl -fsS --max-time 30 "$PRELAND_URL/?nc=$(date +%s)")
|
||||
api=$(printf '%s' "$page" | grep -o 'const createApiUrl = "[^"]*"' || true)
|
||||
echo "$api"
|
||||
case "$api" in *'zoomzilla.ru\/api\/images\/public\/create'*) ;; *) echo "на странице нет адреса загрузки Zoomzilla"; exit 1 ;; esac
|
||||
printf '%s' "$page" | grep -q 'claim?key=' || { echo "переход не на claim"; exit 1; }
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 523 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 719 KiB After Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 325 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 533 KiB After Width: | Height: | Size: 716 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 535 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -30,7 +30,11 @@
|
||||
такого выпуска фактический stage-config следует забрать и зафиксировать в Git.
|
||||
`requisites.html` также живёт только на хостинге и не перезаписывается. Не
|
||||
создавать target-specific конфиги или `config.json.example`.
|
||||
- Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert`
|
||||
- Корневые числовые наборы примеров `/N/` на `zoomstage.online` — редактируемый
|
||||
контент stage. Обычный push в `staging` должен исключать `[0-9]+/` из SFTP-заливки:
|
||||
актуальные примеры нельзя перезаписывать Git-версией. Снимок stage фиксируется в
|
||||
Git отдельной синхронизацией по задаче.
|
||||
- Коммит в `staging` запускает SFTP-автодеплой только на zoomstage.online; откат через `git revert`
|
||||
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
|
||||
- Проверки кода и браузера проводить в Docker без установки ПО на хост.
|
||||
Проверка загрузки по боевому адресу вызывает реальную обработку; выполнять её только
|
||||
@@ -38,3 +42,6 @@
|
||||
|
||||
План, журнал и база знаний ведутся в корне рабочей области по общим правилам. Ссылки на
|
||||
политику и документацию не подключают MCP и не дают разрешения на публикацию или выкат.
|
||||
|
||||
## Актуальная маршрутизация (19.10.2026, preland#4)
|
||||
До отдельного поручения владельца выкатывать только staging → zoomstage.online. Production zoompixel.ru/.online не обновлять. preland.anton.barbariskatest.ru больше не используется, не является целью и не должен упоминаться как требующий выпуска. CI запускает только stage. Сохранение runtime config и stage-наборов /N/ обязательно. Работа и решения — через Gitea.
|
||||
@@ -3,7 +3,7 @@
|
||||
Область действия — репозиторий `preland/`. Общие правила рабочей области находятся в
|
||||
[AGENTS.md](../AGENTS.md) и [CLAUDE.md](../CLAUDE.md); канонический процесс и каталог ролей —
|
||||
[в общей политике](../.known-base/Соглашения/Процесс%20командной%20разработки%20и%20каталог%20ролей.md).
|
||||
Этот файл и [AGENTS.md](AGENTS.md) содержат одинаковые локальные правила; обновлять их вместе.
|
||||
Этот файл и [CLAUDE.md](CLAUDE.md) содержат одинаковые локальные правила; обновлять их вместе.
|
||||
|
||||
## Перед работой
|
||||
|
||||
@@ -21,10 +21,19 @@
|
||||
переход напрямую на `/public/upscale` не заменяет передачу владельца через claim.
|
||||
- `host` задаёт сайт назначения; HTTPS необходим для `crypto.randomUUID()`.
|
||||
Не обходить серверные правила доступа к загрузке и результату обработки.
|
||||
- Рабочий `config.json` единственный для всех прилендов: это точная проверенная
|
||||
конфигурация `zoomstage.online`, и каждый FTP/SFTP-deploy обязан доставлять её на
|
||||
stage и production. `requisites.html` по-прежнему живёт только на хостинге и не
|
||||
перезаписывается. Не создавать target-specific конфиги или `config.json.example`.
|
||||
- Рабочий `config.json` на каждом хостинге настраивает владелец. Деплой не имеет
|
||||
права перезаписывать его значений. При появлении новой обязательной UTM workflow
|
||||
получает текущий хостовый конфиг и меняет только ключи, которые прямо
|
||||
согласованы в задаче. Иные значения, включая уже настроенные UTM, остаются
|
||||
байт-в-байт прежними. Для #274 `bin/ensure-focus-config.php` — явное
|
||||
исключение: он задаёт только новый `focus=face` (default и rnd). После каждого
|
||||
такого выпуска фактический stage-config следует забрать и зафиксировать в Git.
|
||||
`requisites.html` также живёт только на хостинге и не перезаписывается. Не
|
||||
создавать target-specific конфиги или `config.json.example`.
|
||||
- Корневые числовые наборы примеров `/N/` на `zoomstage.online` — редактируемый
|
||||
контент stage. Обычный push в `staging` должен исключать `[0-9]+/` из SFTP-заливки:
|
||||
актуальные примеры нельзя перезаписывать Git-версией. Снимок stage фиксируется в
|
||||
Git отдельной синхронизацией по задаче.
|
||||
- Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert`
|
||||
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
|
||||
- Проверки кода и браузера проводить в Docker без установки ПО на хост.
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
Варианты страницы и сайта (A/B) задаются метками в адресе. Код — обычный PHP без фреймворка и сборки; переводы —
|
||||
маленькая заглушка `Yii::t` в `i18n.php`.
|
||||
|
||||
Боевой экземпляр: `https://preland.anton.barbariskatest.ru` (хостинг beget). Пример ссылки входа:
|
||||
`https://preland.anton.barbariskatest.ru/?indpic=1&theme=dark°r=2®istration=after&bchoice=a&tp=1&utm_source=test&subid=CLICK1`.
|
||||
Активные production: `https://zoompixel.ru`, `https://zoompixel.online`; текущая цель выката — только `https://zoomstage.online`. Пример ссылки входа:
|
||||
`https://zoomstage.online/?indpic=1&theme=dark°r=2®istration=after&bchoice=a&tp=1&utm_source=test&subid=CLICK1`.
|
||||
|
||||
## Как работает
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
Ненастроенные параметры (`utm_source`, `keyword`, `subid` и любые другие) проходят как есть. Перед переходом
|
||||
Preland всегда формирует полный resolved-набор своих восьми меток: явное допустимое значение остаётся, `rnd`
|
||||
разрешается по весам, отсутствующее получает `default`. Вместе с внешними рекламными параметрами этот набор
|
||||
уходит в `claim`, а затем сайт переносит его в `upscale`; ни один из семи параметров не должен пропадать только
|
||||
уходит в `claim`, а затем сайт переносит его в `upscale`; ни один из восьми параметров не должен пропадать только
|
||||
потому, что был назначен default.
|
||||
|
||||
| Метка | Что задаёт | Где |
|
||||
@@ -103,6 +103,12 @@ N/05_...
|
||||
домена целиком: без переименования, вложения в `images/` и промежуточных
|
||||
каталогов. Устаревшие `images/demo*` не являются частью поставки.
|
||||
|
||||
Для `zoomstage.online` обычный deploy ветки `staging` намеренно **не загружает**
|
||||
корневые числовые каталоги `/N/`: это редактируемый контент stage, а не артефакт
|
||||
кодовой доставки. Workflow продолжает проверять их доступность HTTP-smoke. Чтобы
|
||||
зафиксировать обновлённый набор, сначала синхронизируйте его со stage в Git по
|
||||
отдельной задаче; следующая заливка не перетрёт его старой ревизией.
|
||||
|
||||
## Деплой на произвольный домен
|
||||
|
||||
**Требования.** PHP 8.0+ (боевой хостинг — 8.2), веб-сервер с `index.php` в роли индексного файла и **HTTPS**:
|
||||
@@ -114,10 +120,7 @@ N/05_...
|
||||
git clone http://git.dira89kv.beget.tech:3000/anton/preland.git && cd preland
|
||||
# дальше — загрузка на хостинг: rsync/scp по SSH или FTP-клиент
|
||||
```
|
||||
3. **Конфиг и реквизиты.** Рабочий `config.json` уже лежит в репозитории и обязателен к доставке на каждый
|
||||
приленд. Его не заменяют локальным вариантом и не исключают из синхронизации. Реквизиты задаются ключом
|
||||
`requisites` в нём либо runtime-файлом `requisites.html`. 2026-10-05 конфиг пропал после заливки,
|
||||
и преленд слал загрузку на собственный домен; это больше не допускается.
|
||||
3. **Конфиг и реквизиты.** Рабочий `config.json` на хостинге — источник истины. При первичном создании домена его настраивают отдельно; обычный deploy не заменяет его репозиторным снимком и не перезаписывает `requisites.html`. Менять можно только явно согласованные ключи после чтения текущего файла.
|
||||
4. **nginx без Apache** (если хостинг не обрабатывает `.htaccess`):
|
||||
```nginx
|
||||
server {
|
||||
@@ -148,37 +151,25 @@ N/05_...
|
||||
|
||||
## Автодеплой (Gitea Actions)
|
||||
|
||||
`.gitea/workflows/deploy.yml`: после каждого коммита в `main` раннер заливает файлы репозитория на хостинги **всех
|
||||
целей** по FTP с TLS и проверяет страницу каждой. Цель — домен со своим хостингом; список целей — `matrix` в workflow:
|
||||
Решение владельца от 19.10.2026, задача preland#4: сейчас выкатываем **только stage**.
|
||||
|
||||
```yaml
|
||||
- name: barbariskatest # имя цели (видно в списке прогонов: deploy barbariskatest)
|
||||
url: https://preland.anton.barbariskatest.ru # адрес преленда — для проверки после заливки
|
||||
prefix: PRELAND # секреты PRELAND_FTP_HOST, PRELAND_FTP_USER, PRELAND_FTP_PASS
|
||||
dir: "" # каталог сайта на FTP: "" — корень FTP (beget), иначе, например, public_html
|
||||
```
|
||||
| Экземпляр | Назначение | Выкат |
|
||||
|---|---|---|
|
||||
| https://zoomstage.online | Тестовый стенд | Push в staging, SFTP |
|
||||
| https://zoompixel.ru | Production | Отключён до отдельного поручения |
|
||||
| https://zoompixel.online | Production | Отключён до отдельного поручения |
|
||||
|
||||
Цели выкатываются независимо (`fail-fast: false`): сбой одного хостинга не останавливает остальные.
|
||||
Прежний preland.anton.barbariskatest.ru выведен из использования и исключён из workflow.
|
||||
Публикация изменений самого workflow в main не запускает доставку: триггер оставлен только для staging.
|
||||
|
||||
**Новый домен на своём хостинге:**
|
||||
1. Домен и HTTPS — как в «Деплой на произвольный домен», шаг 1.
|
||||
2. Секреты `<ПРЕФИКС>_FTP_HOST`, `<ПРЕФИКС>_FTP_USER`, `<ПРЕФИКС>_FTP_PASS` (Gitea → настройки репозитория →
|
||||
Actions → секреты; порт 21, FTP с TLS). В коде реквизитов нет.
|
||||
3. Никаких ручных конфигов не создавать: единственный `config.json` приезжает вместе с кодом.
|
||||
4. Запись в `matrix` — коммит в `main`, автодеплой выкатит все цели вместе с конфигом.
|
||||
Workflow `.gitea/workflows/deploy.yml` содержит единственную job deploy-zoomstage на
|
||||
выделенном runner zoomzilla-production. Production возвращается только отдельной задачей
|
||||
и прямым поручением владельца. Ошибки старого FTP больше не являются блокером выпуска.
|
||||
|
||||
**Что заливается.** Каждый deploy получает файлы репозитория (`git ls-files`), кроме служебных
|
||||
(`.gitea/`, `README.md`, `.gitignore`) и живущего на хостинге `requisites.html`. Единственный
|
||||
версионируемый `config.json` всегда доставляется вместе с кодом.
|
||||
На хостинге ничего не удаляется: файлы, убранные из репозитория, удалять вручную.
|
||||
Рабочие config.json/requisites.html на хостинге не перезаписываются. Корневые наборы
|
||||
/N/ принадлежат stage и исключены из кодовой доставки. Родительские каталоги создаются
|
||||
штатным SFTP-генератором; smoke проверяет страницу и assets наборов1/2.
|
||||
Откат — отдельный согласованный revert в staging, который обновляет только stage.
|
||||
|
||||
**Проверка после заливки.** На странице цели должен быть адрес загрузки на сайте, а не на самом преленде (иначе нет
|
||||
`config.json` с `host`), и переход на `claim`. Иначе прогон этой цели падает.
|
||||
|
||||
**Откат** — `git revert` проблемного коммита в `main`: автодеплой зальёт прежнюю версию на все цели.
|
||||
|
||||
**SFTP.** Сейчас только FTP с TLS. Хостинг без FTP — поле `protocol` в цели и `lftp` вместо curl в шаге заливки.
|
||||
|
||||
**Откат вручную.** Перед заменой файлов — копия изменяемых файлов (`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,99 @@
|
||||
<?php /* Лёгкая inline-копия API browser-upload: единый контракт, без внешнего запроса. */ ?>
|
||||
<script>
|
||||
(() => {
|
||||
if (window.ZoomzillaUpload) return;
|
||||
const android = /Android/i.test(navigator.userAgent);
|
||||
const mobile = android || /iPhone|iPad|iPod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
document.documentElement.dataset.uploadDevice = android ? 'android' : (mobile ? 'mobile' : 'desktop');
|
||||
let uploadQueue = Promise.resolve();
|
||||
window.ZoomzillaUpload = {
|
||||
// Резерв из пяти точек задаётся CSS: слово и соседняя иконка не двигаются.
|
||||
renderLoadingLabel(label, dots, text) {
|
||||
if (!label) return;
|
||||
let output = label.querySelector('[data-upload-dots]');
|
||||
if (!output) {
|
||||
label.textContent = text + ' ';
|
||||
const slot = document.createElement('span');
|
||||
slot.className = 'upload-loading-dots';
|
||||
slot.setAttribute('aria-hidden', 'true');
|
||||
output = document.createElement('span');
|
||||
output.dataset.uploadDots = '';
|
||||
slot.append(output);
|
||||
label.append(slot);
|
||||
}
|
||||
output.textContent = '.'.repeat(dots);
|
||||
},
|
||||
bindPicker(form, mainInput, fullAccept, onFiles) {
|
||||
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>
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
/** Общая inline-логика Index/Preland: синхронная копия в обоих репозиториях. */
|
||||
return <<<'JS'
|
||||
window.ZoomzillaExamples = {
|
||||
attach(mount) {
|
||||
const root = document.getElementById('examples');
|
||||
if (!root) return;
|
||||
const element = root.querySelector('#image-compare');
|
||||
const visual = root.querySelector('[data-examples-visual]');
|
||||
const retry = root.querySelector('[data-examples-retry]');
|
||||
const items = [...root.querySelectorAll('.image-examples-item')];
|
||||
const media = window.matchMedia('(max-width: 767.98px)');
|
||||
const images = new Map();
|
||||
const imageAttempts = new Map();
|
||||
const pairs = new Map();
|
||||
const failed = new Set();
|
||||
const retrying = new Set();
|
||||
const attemptedAt = new Map();
|
||||
let generation = 0;
|
||||
let active = 1;
|
||||
let locked = true;
|
||||
let mounted = false;
|
||||
let backgroundGeneration = 0;
|
||||
let retryQueue = Promise.resolve();
|
||||
const device = () => media.matches ? 'mob' : 'desk';
|
||||
const itemNumber = item => Number(item.dataset.example);
|
||||
const pairKey = (number, variant) => variant + ':' + number;
|
||||
|
||||
window.examples = {
|
||||
el: root,
|
||||
hide() { window.scrollTo(0, 0); root.style.display = 'none'; },
|
||||
show() { root.style.display = ''; },
|
||||
setTitle(html) { const title = document.getElementById('form-title'); if (title) title.innerHTML = html; }
|
||||
};
|
||||
|
||||
function loadImage(url, priority = 'low') {
|
||||
if (images.has(url)) return images.get(url);
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.fetchPriority = priority;
|
||||
const timer = window.setTimeout(() => finish(new Error('example_timeout')), 15000);
|
||||
let finished = false;
|
||||
function finish(error) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
window.clearTimeout(timer);
|
||||
image.onload = image.onerror = null;
|
||||
if (error) { imageAttempts.set(url, (imageAttempts.get(url) || 0) + 1); reject(error); }
|
||||
else resolve(image);
|
||||
}
|
||||
image.onerror = () => finish(new Error('example_image_failed'));
|
||||
image.onload = async () => {
|
||||
try {
|
||||
if (!image.naturalWidth) throw new Error('example_image_empty');
|
||||
if (image.decode) await image.decode();
|
||||
finish();
|
||||
} catch (error) { finish(error); }
|
||||
};
|
||||
// WebKit может запомнить сетевой отказ URL: повтору нужен новый запрос,
|
||||
// при этом успешно декодированные изображения остаются в нашем кеше.
|
||||
const attempt = imageAttempts.get(url) || 0;
|
||||
image.src = attempt ? url + (url.includes('?') ? '&' : '?') + '_example_retry=' + attempt : url;
|
||||
});
|
||||
images.set(url, promise);
|
||||
promise.catch(() => { if (images.get(url) === promise) images.delete(url); });
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function loadCandidates(item, role, variant, priority) {
|
||||
const suffix = variant === 'mob' ? 'Mob' : '';
|
||||
const candidates = [item.dataset[role + suffix]];
|
||||
if (role === 'thumb') {
|
||||
for (const format of ['Webp', 'Jpg']) {
|
||||
const fallback = item.dataset['thumb' + suffix + format];
|
||||
if (fallback) candidates.push(fallback);
|
||||
}
|
||||
}
|
||||
let failure;
|
||||
for (const url of candidates) {
|
||||
try { return await loadImage(url, priority); } catch (error) { failure = error; }
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
|
||||
async function preparePair(item, variant, priority = 'low') {
|
||||
const key = pairKey(itemNumber(item), variant);
|
||||
if (pairs.has(key)) return pairs.get(key);
|
||||
const pair = await Promise.all([
|
||||
loadCandidates(item, 'source', variant, priority),
|
||||
loadCandidates(item, 'result', variant, priority)
|
||||
]);
|
||||
pairs.set(key, pair);
|
||||
failed.delete(key);
|
||||
return pair;
|
||||
}
|
||||
|
||||
function replaceImage(previous, next) {
|
||||
if (previous === next) return;
|
||||
for (const attribute of [...previous.attributes]) {
|
||||
if (!['src', 'srcset', 'sizes'].includes(attribute.name)) next.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
previous.replaceWith(next);
|
||||
}
|
||||
|
||||
function refreshItems() {
|
||||
for (const item of items) {
|
||||
const ready = !locked && pairs.has(pairKey(itemNumber(item), device()));
|
||||
item.setAttribute('aria-disabled', String(!ready));
|
||||
item.tabIndex = ready ? 0 : -1;
|
||||
item.classList.toggle('active', itemNumber(item) === active);
|
||||
}
|
||||
}
|
||||
|
||||
function commitPair(number, pair) {
|
||||
// Обе декодированные DOM-картинки заменяются в одном JS-такте, без промежуточного paint.
|
||||
replaceImage(element.querySelector('#image-compare-source'), pair[0]);
|
||||
replaceImage(element.querySelector('#image-compare-result'), pair[1]);
|
||||
active = number;
|
||||
if (!mounted) { mount(element); mounted = true; }
|
||||
element.dispatchEvent(new Event('examples:images'));
|
||||
refreshItems();
|
||||
}
|
||||
|
||||
function select(item) {
|
||||
const number = itemNumber(item);
|
||||
const pair = pairs.get(pairKey(number, device()));
|
||||
if (locked) return;
|
||||
if (!pair) { retryFailed(item); return; }
|
||||
if (number === active) return;
|
||||
commitPair(number, pair);
|
||||
}
|
||||
|
||||
async function retryFailed(item) {
|
||||
const variant = device();
|
||||
const key = pairKey(itemNumber(item), variant);
|
||||
if (locked || backgroundGeneration === generation || !failed.has(key) || retrying.has(key) || Date.now() - (attemptedAt.get(key) || 0) < 1000) return;
|
||||
const token = generation;
|
||||
retrying.add(key);
|
||||
attemptedAt.set(key, Date.now());
|
||||
const request = retryQueue.then(() => {
|
||||
if (token === generation && !locked) return preparePair(item, variant);
|
||||
});
|
||||
retryQueue = request.catch(() => {});
|
||||
try { await request; } catch (_) { /* Сеть может всё ещё быть недоступна. */ }
|
||||
finally { retrying.delete(key); if (!locked && device() === variant) refreshItems(); }
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
item.setAttribute('role', 'button');
|
||||
item.addEventListener('click', event => { event.preventDefault(); select(item); });
|
||||
// Повтор не выбирает неготовую пару и не меняет оформление миниатюры.
|
||||
item.addEventListener('pointerenter', () => retryFailed(item));
|
||||
item.addEventListener('touchstart', () => retryFailed(item), {passive: true});
|
||||
item.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); select(item); }
|
||||
});
|
||||
}
|
||||
// Сравнение заблокировано также при смене device-варианта, пока новая пара не готова.
|
||||
for (const name of ['mousedown', 'mousemove', 'touchstart', 'touchmove', 'pointerdown', 'pointermove', 'keydown']) {
|
||||
element.addEventListener(name, event => { if (locked) event.stopImmediatePropagation(); }, true);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const token = ++generation;
|
||||
const variant = device();
|
||||
locked = true;
|
||||
root.dataset.examplesState = 'loading';
|
||||
root.setAttribute('aria-busy', 'true');
|
||||
retry.hidden = true;
|
||||
refreshItems();
|
||||
try {
|
||||
const item = items.find(item => itemNumber(item) === active);
|
||||
const pair = await preparePair(item, variant, 'high');
|
||||
if (token !== generation) return;
|
||||
// Только после выбранной пары разрешены запросы всех миниатюр.
|
||||
const thumbs = await Promise.all(items.map(item => loadCandidates(item, 'thumb', variant, 'auto')));
|
||||
if (document.fonts) await new Promise((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => reject(new Error('example_font_timeout')), 15000);
|
||||
document.fonts.ready.then(() => { window.clearTimeout(timer); resolve(); }, error => { window.clearTimeout(timer); reject(error); });
|
||||
});
|
||||
if (token !== generation) return;
|
||||
items.forEach((item, index) => replaceImage(item.querySelector('.image-examples-image img'), thumbs[index]));
|
||||
commitPair(active, pair);
|
||||
locked = false;
|
||||
visual.style.visibility = 'visible';
|
||||
visual.inert = false;
|
||||
visual.removeAttribute('aria-hidden');
|
||||
root.dataset.examplesState = 'ready';
|
||||
root.setAttribute('aria-busy', 'false');
|
||||
refreshItems();
|
||||
// Остальные пары последовательно; две стороны одной пары — параллельно.
|
||||
backgroundGeneration = token;
|
||||
for (const next of items) {
|
||||
if (token !== generation) return;
|
||||
if (itemNumber(next) === active) continue;
|
||||
try { await preparePair(next, variant); } catch (_) { failed.add(pairKey(itemNumber(next), variant)); }
|
||||
if (token === generation) refreshItems();
|
||||
}
|
||||
if (backgroundGeneration === token) backgroundGeneration = 0;
|
||||
// Один тихий повтор после очереди; затем — по возврату сети или обращению к миниатюре.
|
||||
if (token === generation && items.some(item => failed.has(pairKey(itemNumber(item), variant)))) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 1000));
|
||||
for (const next of items) {
|
||||
if (token !== generation) return;
|
||||
await retryFailed(next);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
if (token !== generation) return;
|
||||
root.dataset.examplesState = 'error';
|
||||
root.setAttribute('aria-busy', 'false');
|
||||
retry.hidden = false;
|
||||
}
|
||||
}
|
||||
retry.addEventListener('click', run);
|
||||
window.addEventListener('online', () => {
|
||||
if (root.dataset.examplesState === 'error') run();
|
||||
else { attemptedAt.clear(); items.forEach(retryFailed); }
|
||||
});
|
||||
media.addEventListener('change', run);
|
||||
refreshItems();
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, {once: true});
|
||||
else run();
|
||||
}
|
||||
};
|
||||
JS;
|
||||
@@ -1,101 +1,39 @@
|
||||
<?php
|
||||
|
||||
$demoBasePath = rtrim($assetsUriPrefix, '/') . "/{$indpic}";
|
||||
$demoLocalPath = __DIR__ . "/../{$indpic}";
|
||||
|
||||
$getAssetUrl = function (int $number, string $device, string $role) use ($demoBasePath, $demoLocalPath): string {
|
||||
$relativePath = sprintf('%02d', $number) . "_{$device}_{$role}.avif";
|
||||
$fullPath = $demoLocalPath . '/' . ltrim($relativePath, '/');
|
||||
$fullPath = $demoLocalPath . '/' . $relativePath;
|
||||
$version = file_exists($fullPath) ? filemtime($fullPath) : '1';
|
||||
return $demoBasePath . '/' . ltrim($relativePath, '/') . '?v=' . $version;
|
||||
return $demoBasePath . '/' . $relativePath . '?v=' . $version;
|
||||
};
|
||||
|
||||
$labelBefore = Yii::t('app', 'До');
|
||||
$labelAfter = Yii::t('app', 'После');
|
||||
|
||||
$sourceMob = $getAssetUrl(1, 'mob', '1-source');
|
||||
$sourceOrig = $getAssetUrl(1, 'desk', '1-source');
|
||||
|
||||
$resultMob = $getAssetUrl(1, 'mob', '2-result');
|
||||
$resultOrig = $getAssetUrl(1, 'desk', '2-result');
|
||||
|
||||
?>
|
||||
|
||||
<div id="examples">
|
||||
|
||||
<div id="examples" data-examples-state="loading" aria-busy="true">
|
||||
<div data-examples-visual style="visibility:hidden" aria-hidden="true" inert>
|
||||
<div class="image-compare" id="image-compare">
|
||||
<img src="<?= $sourceMob ?>"
|
||||
srcset="<?= $sourceMob ?> 768w, <?= $sourceOrig ?> 1696w"
|
||||
sizes="(max-width: 767.98px) 100vw, 728px"
|
||||
id="image-compare-source"
|
||||
fetchpriority="high"
|
||||
loading="eager"
|
||||
width="728"
|
||||
height="395"
|
||||
alt="<?= htmlspecialchars($labelBefore) ?>">
|
||||
|
||||
<img src="<?= $resultMob ?>"
|
||||
srcset="<?= $resultMob ?> 768w, <?= $resultOrig ?> 1696w"
|
||||
sizes="(max-width: 767.98px) 100vw, 728px"
|
||||
id="image-compare-result"
|
||||
fetchpriority="high"
|
||||
loading="eager"
|
||||
width="728"
|
||||
height="395"
|
||||
alt="<?= htmlspecialchars($labelAfter) ?>">
|
||||
<img id="image-compare-source" width="728" height="395" alt="<?= htmlspecialchars($labelBefore) ?>">
|
||||
<img id="image-compare-result" width="728" height="395" alt="<?= htmlspecialchars($labelAfter) ?>">
|
||||
</div>
|
||||
|
||||
<div class="image-examples">
|
||||
<div class="image-examples-label"><?= Yii::t('app', 'Примеры улучшения') ?></div>
|
||||
<div class="image-examples-list">
|
||||
<?php for ($i = 1; $i <= 5; $i++):
|
||||
$thumbMob = $getAssetUrl($i, 'mob', '3-thumb');
|
||||
$thumbOrig = $getAssetUrl($i, 'desk', '3-thumb');
|
||||
?>
|
||||
<div class="image-examples-item <?= $i === 1 ? 'active' : '' ?>"
|
||||
data-index="<?= $i ?>"
|
||||
data-source="<?= $getAssetUrl($i, 'desk', '1-source') ?>"
|
||||
data-result="<?= $getAssetUrl($i, 'desk', '2-result') ?>"
|
||||
data-source-mob="<?= $getAssetUrl($i, 'mob', '1-source') ?>"
|
||||
data-result-mob="<?= $getAssetUrl($i, 'mob', '2-result') ?>">
|
||||
<div class="image-examples-image">
|
||||
<?php if ($i === 1): ?>
|
||||
<picture>
|
||||
<source media="(max-width: 767.98px)" srcset="<?= $thumbMob ?>">
|
||||
<img src="<?= $thumbOrig ?>" width="53" height="53" alt="Example 1">
|
||||
</picture>
|
||||
<?php else: ?>
|
||||
<picture class="deferred-picture">
|
||||
<source media="(max-width: 767.98px)" data-srcset="<?= $thumbMob ?>">
|
||||
<img data-src="<?= $thumbOrig ?>" width="53" height="53" alt="Example <?= $i ?>" class="deferred-thumb">
|
||||
</picture>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php for ($i=1; $i<=5; $i++): ?>
|
||||
<div class="image-examples-item <?= $i===1 ? 'active' : '' ?>" data-example="<?= $i ?>" data-index="<?= $i ?>"
|
||||
data-source="<?= $getAssetUrl($i, 'desk', '1-source') ?>" data-result="<?= $getAssetUrl($i, 'desk', '2-result') ?>"
|
||||
data-source-mob="<?= $getAssetUrl($i, 'mob', '1-source') ?>" data-result-mob="<?= $getAssetUrl($i, 'mob', '2-result') ?>"
|
||||
data-thumb="<?= $getAssetUrl($i, 'desk', '3-thumb') ?>" data-thumb-mob="<?= $getAssetUrl($i, 'mob', '3-thumb') ?>" aria-disabled="true" tabindex="-1">
|
||||
<div class="image-examples-image"><picture><img width="100" height="100" alt="Example <?= $i ?>"></picture></div>
|
||||
</div>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn button-outline examples-retry" data-examples-retry hidden><?= Yii::t('app', 'Повторить') ?></button>
|
||||
</div>
|
||||
<script>
|
||||
const examples = {
|
||||
el: document.getElementById('examples'),
|
||||
titleEl: document.getElementById('form-title'),
|
||||
|
||||
hide() {
|
||||
window.scrollTo(0, 0);
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
if (this.el) this.el.style.display = 'none';
|
||||
},
|
||||
show() {
|
||||
if (this.el) this.el.style.display = '';
|
||||
},
|
||||
setTitle(html) {
|
||||
if (this.titleEl) this.titleEl.innerHTML = html;
|
||||
}
|
||||
};
|
||||
|
||||
<?= require __DIR__ . '/example-loader.php' ?>
|
||||
function setupNativeCompare(element, labelBefore, labelAfter) {
|
||||
if (!element || element.classList.contains('native-icv') || element.classList.contains('icv')) return null;
|
||||
|
||||
@@ -132,15 +70,18 @@ $resultOrig = $getAssetUrl(1, 'desk', '2-result');
|
||||
element.appendChild(lblBefore);
|
||||
element.appendChild(lblAfter);
|
||||
|
||||
let sliderPercent = 50;
|
||||
element.addEventListener('examples:images', () => updateSlider(sliderPercent));
|
||||
function updateSlider(percent) {
|
||||
sliderPercent = percent;
|
||||
percent = Math.max(0, Math.min(100, percent));
|
||||
beforeWrap.style.width = percent + '%';
|
||||
handle.style.left = percent + '%';
|
||||
sourceImg.style.width = element.clientWidth + 'px';
|
||||
element.querySelector('#image-compare-source').style.width = element.clientWidth + 'px';
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
sourceImg.style.width = element.clientWidth + 'px';
|
||||
element.querySelector('#image-compare-source').style.width = element.clientWidth + 'px';
|
||||
});
|
||||
|
||||
let isDragging = false;
|
||||
@@ -175,140 +116,10 @@ $resultOrig = $getAssetUrl(1, 'desk', '2-result');
|
||||
|
||||
updateSlider(50);
|
||||
|
||||
return {
|
||||
setImages(sourceUrl, resultUrl) {
|
||||
sourceImg.removeAttribute('srcset');
|
||||
resultImg.removeAttribute('srcset');
|
||||
sourceImg.src = sourceUrl;
|
||||
resultImg.src = resultUrl;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function initCompareViewer() {
|
||||
const element = document.getElementById('image-compare');
|
||||
const beforeTxt = '<?= addslashes($labelBefore) ?>';
|
||||
const afterTxt = '<?= addslashes($labelAfter) ?>';
|
||||
|
||||
if (element && typeof ImageCompare !== 'undefined') {
|
||||
const options = {
|
||||
controlColor: "#FFFFFF",
|
||||
controlShadow: true,
|
||||
addCircle: true,
|
||||
addCircleBlur: true,
|
||||
showLabels: true,
|
||||
labelOptions: { before: beforeTxt, after: afterTxt, onHover: false },
|
||||
smoothing: true,
|
||||
smoothingAmount: 100,
|
||||
hoverStart: false,
|
||||
verticalMode: false,
|
||||
startingPoint: 50,
|
||||
fluidMode: false
|
||||
};
|
||||
window.icvInstance = new ImageCompare(element, options).mount();
|
||||
} else if (element) {
|
||||
window.nativeCompareInstance = setupNativeCompare(element, beforeTxt, afterTxt);
|
||||
}
|
||||
|
||||
const exampleItems = document.querySelectorAll('.image-examples-item');
|
||||
exampleItems.forEach(item => {
|
||||
item.onclick = function() {
|
||||
const isMobile = window.innerWidth < 768;
|
||||
// На мобильном берем source_mob, на ПК — полный оригинал
|
||||
const source = (isMobile && this.getAttribute('data-source-mob'))
|
||||
? this.getAttribute('data-source-mob')
|
||||
: this.getAttribute('data-source');
|
||||
const result = (isMobile && this.getAttribute('data-result-mob'))
|
||||
? this.getAttribute('data-result-mob')
|
||||
: this.getAttribute('data-result');
|
||||
|
||||
if (window.icvInstance) {
|
||||
window.icvInstance.images.start.element.removeAttribute('srcset');
|
||||
window.icvInstance.images.end.element.removeAttribute('srcset');
|
||||
window.icvInstance.images.start.element.src = source;
|
||||
window.icvInstance.images.end.element.src = result;
|
||||
} else if (window.nativeCompareInstance) {
|
||||
window.nativeCompareInstance.setImages(source, result);
|
||||
} else {
|
||||
const sourceEl = document.getElementById('image-compare-source');
|
||||
const resultEl = document.getElementById('image-compare-result');
|
||||
if (sourceEl) {
|
||||
sourceEl.removeAttribute('srcset');
|
||||
sourceEl.src = source;
|
||||
}
|
||||
if (resultEl) {
|
||||
resultEl.removeAttribute('srcset');
|
||||
resultEl.src = result;
|
||||
}
|
||||
}
|
||||
|
||||
exampleItems.forEach(el => el.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
};
|
||||
window.ZoomzillaExamples.attach(element => {
|
||||
setupNativeCompare(element, <?= json_encode($labelBefore, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, <?= json_encode($labelAfter, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
|
||||
});
|
||||
}
|
||||
|
||||
function initStrictImagePipeline() {
|
||||
initCompareViewer();
|
||||
|
||||
const preloadUrl = (url) => {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = img.onerror = resolve;
|
||||
img.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
const deferredPics = document.querySelectorAll('.deferred-picture');
|
||||
const thumbPromises = [];
|
||||
|
||||
deferredPics.forEach(pic => {
|
||||
const source = pic.querySelector('source');
|
||||
const img = pic.querySelector('img');
|
||||
|
||||
if (source && source.getAttribute('data-srcset')) {
|
||||
source.srcset = source.getAttribute('data-srcset');
|
||||
}
|
||||
if (img && img.getAttribute('data-src')) {
|
||||
const p = new Promise(resolve => {
|
||||
img.onload = img.onerror = resolve;
|
||||
img.src = img.getAttribute('data-src');
|
||||
});
|
||||
thumbPromises.push(p);
|
||||
}
|
||||
});
|
||||
|
||||
Promise.all(thumbPromises).then(() => {
|
||||
const queue = [2, 3, 4, 5];
|
||||
|
||||
async function loadSequentially() {
|
||||
const isMobile = window.innerWidth < 768;
|
||||
for (const num of queue) {
|
||||
const item = document.querySelector(`.image-examples-item[data-index="${num}"]`);
|
||||
if (!item) continue;
|
||||
|
||||
const s = (isMobile && item.getAttribute('data-source-mob'))
|
||||
? item.getAttribute('data-source-mob')
|
||||
: item.getAttribute('data-source');
|
||||
const r = (isMobile && item.getAttribute('data-result-mob'))
|
||||
? item.getAttribute('data-result-mob')
|
||||
: item.getAttribute('data-result');
|
||||
|
||||
await Promise.all([preloadUrl(s), preloadUrl(r)]);
|
||||
}
|
||||
}
|
||||
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => loadSequentially());
|
||||
} else {
|
||||
setTimeout(loadSequentially, 150);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initStrictImagePipeline);
|
||||
} else {
|
||||
initStrictImagePipeline();
|
||||
}
|
||||
</script>
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/browser-upload.php';
|
||||
echo '<script>', require __DIR__ . '/navigation-guard.php', '</script>';
|
||||
echo '<script>', require __DIR__ . '/upload-recovery.php', '</script>';
|
||||
|
||||
$errorMessage = Yii::t('app', 'Возникла ошибка');
|
||||
$utmJson = json_encode($UTM ?? [], JSON_UNESCAPED_UNICODE);
|
||||
@@ -11,11 +14,11 @@ $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">
|
||||
<img src="<?= $assetsUriPrefix; ?>/images/dropzone-icon.svg" alt="" height="64" width="64">
|
||||
<img src="<?= $assetsUriPrefix; ?>/images/dropzone-icon.svg" alt="" height="65" width="64">
|
||||
</div>
|
||||
<div id="form-title" class="dropzone-form-text"><?= Yii::t('app', 'Загрузите изображение') ?> <br class="d-md-none"><?= Yii::t('app', 'для улучшения') ?></div>
|
||||
<div class="dropzone-form-format">
|
||||
@@ -26,21 +29,23 @@ $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>
|
||||
<div class="dropzone-form-action">
|
||||
<button type="button" class="dropzone-form-button">
|
||||
<span data-upload-label><?= Yii::t('app', 'Загрузить изображение') ?></span>
|
||||
<span data-upload-label><?= Yii::t('app', preg_match('/Android/i', $_SERVER['HTTP_USER_AGENT'] ?? '') ? 'Выбрать фото' : 'Загрузить изображение') ?></span>
|
||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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>
|
||||
|
||||
<input type="file" id="dropzone-file-input" name="file" accept="<?= htmlspecialchars($basePickerAccept, ENT_QUOTES, 'UTF-8') ?>" style="display: none;">
|
||||
<span data-upload-markup-ready hidden></span>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
@@ -78,14 +83,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;
|
||||
}
|
||||
@@ -94,6 +101,11 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
||||
}
|
||||
|
||||
let uploadPending = false;
|
||||
let leavingUploadPage = false;
|
||||
let uploadAttempt = 0;
|
||||
const recovery = window.ZoomzillaUploadRecovery.create({scope: 'preland', target: createApiUrl});
|
||||
const metadata = () => ({actionUrl: createApiUrl, redirectBase: redirectUrlBase, redirectUtm: redirectUrlUtm});
|
||||
const clearRecovery = () => recovery.clear().catch(() => {});
|
||||
let uploadDots = 1;
|
||||
let uploadDotsTimer = null;
|
||||
const uploadButton = formEl.querySelector('.dropzone-form-button');
|
||||
@@ -101,12 +113,14 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
||||
const uploadButtonDefaultLabel = uploadButtonLabel?.textContent || '';
|
||||
|
||||
function renderUploadButton() {
|
||||
if (uploadButtonLabel) uploadButtonLabel.textContent = 'Загрузка' + '.'.repeat(uploadDots);
|
||||
window.ZoomzillaUpload.renderLoadingLabel(uploadButtonLabel, uploadDots, <?= json_encode(Yii::t('app', 'Загрузка'), JSON_UNESCAPED_UNICODE) ?>);
|
||||
}
|
||||
|
||||
function beginUploadPending() {
|
||||
if (uploadPending) return false;
|
||||
uploadPending = true;
|
||||
uploadAttempt++;
|
||||
window.PublicNavigationGuard.activate();
|
||||
uploadDots = 1;
|
||||
renderUploadButton();
|
||||
if (uploadButton) {
|
||||
@@ -114,7 +128,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
||||
uploadButton.setAttribute('aria-busy', 'true');
|
||||
}
|
||||
uploadDotsTimer = window.setInterval(() => {
|
||||
uploadDots = uploadDots === 5 ? 1 : uploadDots + 1;
|
||||
uploadDots = (uploadDots + 1) % 6;
|
||||
renderUploadButton();
|
||||
}, 500);
|
||||
return true;
|
||||
@@ -124,7 +138,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');
|
||||
@@ -181,51 +195,64 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
||||
uploadFile(file);
|
||||
}
|
||||
|
||||
function createClientKey() {
|
||||
return crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
function redirectToTour(record) {
|
||||
let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key);
|
||||
if (record.meta.redirectUtm) targetUrl += '&' + record.meta.redirectUtm;
|
||||
window.PublicNavigationGuard.allowNavigation(() => { window.location.href = targetUrl; });
|
||||
}
|
||||
|
||||
function redirectToTour(clientKey) {
|
||||
let targetUrl = redirectUrlBase + encodeURIComponent(clientKey);
|
||||
if (redirectUrlUtm) targetUrl += '&' + redirectUrlUtm;
|
||||
window.location.href = targetUrl;
|
||||
}
|
||||
|
||||
function rejectOversizedFile(file) {
|
||||
async function rejectOversizedFile(file, saved = null) {
|
||||
if (!beginUploadPending()) return;
|
||||
const clientKey = createClientKey();
|
||||
const attempt = uploadAttempt;
|
||||
try {
|
||||
const record = saved || await recovery.remember(null, {...metadata(), rejectionCode: 105, name: file.name, size: file.size});
|
||||
if (attempt !== uploadAttempt || leavingUploadPage) return;
|
||||
const clientKey = record.key;
|
||||
const formData = new FormData();
|
||||
formData.append('rejection_code', '105');
|
||||
formData.append('original_name', file.name || 'upload');
|
||||
formData.append('original_size', String(file.size || 0));
|
||||
formData.append('client_key', clientKey);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', createApiUrl || formEl.getAttribute('action') || '/api/images/public/create', true);
|
||||
xhr.open('POST', record.meta.actionUrl, true);
|
||||
xhr.withCredentials = true;
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
xhr.onload = () => {
|
||||
xhr.onload = async () => {
|
||||
if (attempt !== uploadAttempt || leavingUploadPage) return;
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
redirectToTour(clientKey);
|
||||
try { const result = JSON.parse(xhr.responseText); if (!result?.id) throw new Error(); await recovery.accepted({id: result.id}); redirectToTour(record); }
|
||||
catch (_) { showUploadError(genericErrorMsg); }
|
||||
return;
|
||||
}
|
||||
showUploadError(genericErrorMsg);
|
||||
};
|
||||
xhr.onerror = () => showUploadError(genericErrorMsg);
|
||||
xhr.onerror = () => { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true); };
|
||||
xhr.send(formData);
|
||||
} catch (_) { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg); }
|
||||
}
|
||||
|
||||
function uploadFile(file) {
|
||||
function uploadFile(file, saved = null) {
|
||||
const attempt = uploadAttempt;
|
||||
// Тот же обход нестабильного Android FileProvider, что на основном сайте.
|
||||
window.ZoomzillaUpload.prepare(file, maxUploadBytes || 15 * 1024 * 1024)
|
||||
.then(async bytes => {
|
||||
if (attempt !== uploadAttempt || leavingUploadPage) return;
|
||||
const record = saved || await recovery.remember(new File([bytes], file.name, {type: file.type, lastModified: file.lastModified}), metadata());
|
||||
if (attempt === uploadAttempt && !leavingUploadPage) uploadPreparedFile(file, bytes, record);
|
||||
})
|
||||
.catch(() => { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg); });
|
||||
}
|
||||
function uploadPreparedFile(file, bytes, record) {
|
||||
const attempt = uploadAttempt;
|
||||
const xhr = new XMLHttpRequest();
|
||||
const formData = new FormData();
|
||||
// Ключ загрузки: по нему сайт передаёт её в сессию посетителя (/api/images/public/claim, годен 30 мин).
|
||||
// randomUUID есть в HTTPS-контексте; где его нет (старый Safari), есть getRandomValues.
|
||||
const clientKey = createClientKey();
|
||||
formData.append('file', file);
|
||||
const clientKey = record.key;
|
||||
formData.append('file', bytes, file.name || 'upload');
|
||||
formData.append('client_key', clientKey);
|
||||
|
||||
const actionUrl = createApiUrl || formEl.getAttribute('action') || '/api/images/public/create';
|
||||
const actionUrl = record.meta.actionUrl;
|
||||
xhr.open('POST', actionUrl, true);
|
||||
xhr.withCredentials = true;
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
@@ -237,7 +264,8 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
xhr.onload = async () => {
|
||||
if (attempt !== uploadAttempt || leavingUploadPage) return;
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
let response;
|
||||
try {
|
||||
@@ -259,24 +287,55 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
||||
}
|
||||
|
||||
// Переход: $redirect_url_base . client_key . ($redirect_url_utm ? '&' . $redirect_url_utm : '')
|
||||
redirectToTour(clientKey);
|
||||
try { await recovery.accepted({id: imageId}); redirectToTour(record); }
|
||||
catch (_) { showUploadError(genericErrorMsg); }
|
||||
} else {
|
||||
showUploadError(genericErrorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
showUploadError(genericErrorMsg);
|
||||
if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true);
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
function showUploadError(msg) {
|
||||
function showUploadError(msg, keepRecovery = false) {
|
||||
if (leavingUploadPage) return;
|
||||
uploadAttempt++;
|
||||
if (!keepRecovery) clearRecovery();
|
||||
resetUploadPending();
|
||||
form.show();
|
||||
if (typeof preview !== 'undefined' && preview) preview.showError(msg);
|
||||
if (typeof preview !== 'undefined' && preview) {
|
||||
preview.show(); preview.showError(msg);
|
||||
if (!preview.sourceEl.getAttribute('src')) preview.sourceEl.style.display = 'none';
|
||||
}
|
||||
// Release sentinel восстанавливает scroll history. Показываем сообщение
|
||||
// после этого перехода, иначе браузер тут же возвращает верх страницы.
|
||||
window.PublicNavigationGuard.allowNavigation(() => requestAnimationFrame(() => {
|
||||
if (typeof preview !== 'undefined' && preview) preview.upscaleErrorOverlay.scrollIntoView({block: 'center', behavior: 'instant'});
|
||||
}));
|
||||
fileInput.value = '';
|
||||
}
|
||||
window.addEventListener('pagehide', () => { leavingUploadPage = true; });
|
||||
window.addEventListener('pageshow', event => {
|
||||
if (!event.persisted) return;
|
||||
leavingUploadPage = false;
|
||||
uploadAttempt++;
|
||||
const wasUploading = uploadPending;
|
||||
resetUploadPending(); clearRecovery(); fileInput.value = '';
|
||||
if (wasUploading) window.PublicNavigationGuard.allowNavigation();
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
const saved = await recovery.load();
|
||||
if (!saved) return;
|
||||
if (performance.getEntriesByType('navigation')[0]?.type !== 'reload' || saved.sourceUrl !== location.href || saved.meta.actionUrl !== createApiUrl) { clearRecovery(); return; }
|
||||
if (saved.result?.id) { beginUploadPending(); redirectToTour(saved); }
|
||||
else if (saved.file) { beginUploadPending(); uploadFile(saved.file, saved); }
|
||||
else if (saved.meta.rejectionCode === 105) rejectOversizedFile(saved.meta, saved);
|
||||
} catch (_) { clearRecovery(); }
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
// Общий inline-контракт Back для незавершённой публичной операции (api#299/preland#9).
|
||||
return <<<'JS'
|
||||
(() => {
|
||||
let active = false;
|
||||
let hasHistoryEntry = false;
|
||||
let releasingHistoryEntry = false;
|
||||
let returningToSentinel = false;
|
||||
|
||||
function pushGuardEntries() {
|
||||
// Помечаем и текущую запись Tour, и sentinel сверху. Иначе первый Back
|
||||
// останавливался на немаркированной записи, а второй мог уйти в about:blank.
|
||||
window.history.replaceState({ publicOperationGuard: true }, '', window.location.href);
|
||||
window.history.pushState({ publicOperationGuard: true }, '', window.location.href);
|
||||
hasHistoryEntry = true;
|
||||
}
|
||||
|
||||
function onPopState(event) {
|
||||
if (releasingHistoryEntry) {
|
||||
releasingHistoryEntry = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
if (returningToSentinel) {
|
||||
returningToSentinel = false;
|
||||
return;
|
||||
}
|
||||
returningToSentinel = true;
|
||||
window.history.go(1);
|
||||
}
|
||||
}
|
||||
|
||||
function deactivate(onReleased) {
|
||||
if (!active) {
|
||||
if (onReleased) {
|
||||
if (releasingHistoryEntry) {
|
||||
window.addEventListener('popstate', onReleased, { once: true });
|
||||
} else {
|
||||
onReleased();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
active = false;
|
||||
|
||||
if (hasHistoryEntry) {
|
||||
hasHistoryEntry = false;
|
||||
releasingHistoryEntry = true;
|
||||
if (onReleased) window.addEventListener('popstate', onReleased, { once: true });
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
if (onReleased) onReleased();
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', onPopState);
|
||||
window.PublicNavigationGuard = {
|
||||
activate() {
|
||||
if (active) return;
|
||||
releasingHistoryEntry = false;
|
||||
returningToSentinel = false;
|
||||
active = true;
|
||||
pushGuardEntries();
|
||||
},
|
||||
deactivate,
|
||||
allowNavigation: deactivate,
|
||||
};
|
||||
})();
|
||||
JS;
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.image-loading-progress {
|
||||
height: 35dvh !important;
|
||||
height: 35svh !important;
|
||||
}
|
||||
.image-loading-progress #source {
|
||||
position: static;
|
||||
|
||||
@@ -22,7 +22,7 @@ $labelsJson = json_encode($labels, JSON_UNESCAPED_UNICODE);
|
||||
</svg>
|
||||
</div>
|
||||
<div class="dropzone-form-loading-info-text">
|
||||
<span id="origin_width"><?= htmlspecialchars($origin_width ?? '', ENT_QUOTES, 'UTF-8') ?></span> x <span id="origin_height"><?= htmlspecialchars($origin_height ?? '', ENT_QUOTES, 'UTF-8') ?></span> <span>px</span></div>
|
||||
<span id="origin_width"><?= htmlspecialchars($origin_width ?? '', ENT_QUOTES, 'UTF-8') ?></span>×<span id="origin_height"><?= htmlspecialchars($origin_height ?? '', ENT_QUOTES, 'UTF-8') ?></span> <span class="process-dimensions-unit">px</span></div>
|
||||
</div>
|
||||
<div class="dropzone-form-loading-info-item">
|
||||
<div class="dropzone-form-loading-info-icon">
|
||||
@@ -48,7 +48,7 @@ $labelsJson = json_encode($labels, JSON_UNESCAPED_UNICODE);
|
||||
</svg>
|
||||
</div>
|
||||
<div class="dropzone-form-loading-info-text">
|
||||
<span id="upscale_width"><?= htmlspecialchars($upscale_width ?? '', ENT_QUOTES, 'UTF-8') ?></span> x <span id="upscale_height"><?= htmlspecialchars($upscale_height ?? '', ENT_QUOTES, 'UTF-8') ?></span> <span>px</span></div>
|
||||
<span id="upscale_width"><?= htmlspecialchars($upscale_width ?? '', ENT_QUOTES, 'UTF-8') ?></span>×<span id="upscale_height"><?= htmlspecialchars($upscale_height ?? '', ENT_QUOTES, 'UTF-8') ?></span> <span class="process-dimensions-unit">px</span></div>
|
||||
</div>
|
||||
<div class="dropzone-form-loading-info-item">
|
||||
<div class="dropzone-form-loading-info-icon">
|
||||
@@ -133,7 +133,7 @@ $labelsJson = json_encode($labels, JSON_UNESCAPED_UNICODE);
|
||||
if (bytes === 0 || isNaN(bytes)) return '0 B';
|
||||
const units = ['B', 'Kb', 'Mb', 'Gb', 'Tb'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const formattedValue = (bytes / Math.pow(1024, i)).toFixed(2);
|
||||
const formattedValue = (bytes / Math.pow(1024, i)).toFixed(i === 1 ? 0 : 2);
|
||||
return parseFloat(formattedValue) + ' ' + units[i];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php /* CSS синхронна API upload-picker-links; отдельный хост без Yii assets. */ ?>
|
||||
|
||||
<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">
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/** Синхронная inline-CSS API/Preland: мобильный вид до инициализации picker. */
|
||||
return <<<'CSS'
|
||||
[data-picker-or][hidden], [data-files-picker][hidden] { display: none !important; }
|
||||
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) { padding: 16px; }
|
||||
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-icon img { width: 44px; height: 44px; }
|
||||
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-text { margin-top: 8px; font-size: 16px; line-height: 24px; }
|
||||
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-format { margin-top: 10px; flex-direction: column; gap: 6px; }
|
||||
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-format-label { font-size: 11px; line-height: 16px; }
|
||||
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-format-list { flex-wrap: nowrap; gap: 4px; justify-content: center; }
|
||||
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-format-item { padding: 3px 5px; border-radius: 6px; font-size: 11px; line-height: 15px; white-space: nowrap; }
|
||||
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-action { margin-top: 12px; display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
||||
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-button { width: 100%; pointer-events: auto; }
|
||||
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) [data-picker-or] { display: block; margin: 0; font-size: 10px; line-height: 14px; color: #979aa0; }
|
||||
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) [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; }
|
||||
:is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-format-item[data-format-optional] { display: none; }
|
||||
html[data-upload-device=android] #dropzone-form [data-picker-or][hidden], html[data-upload-device=android] #dropzone-form [data-files-picker][hidden] { display: block !important; }
|
||||
CSS;
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
/** Общее восстановление текущей публичной загрузки; UI и навигацией управляет вызывающая форма. */
|
||||
return <<<'JS'
|
||||
(() => {
|
||||
if (window.ZoomzillaUploadRecovery) return;
|
||||
const ttl = 30 * 60 * 1000;
|
||||
const prefix = 'zoomzilla-upload-recovery:';
|
||||
const slots = new Map();
|
||||
const randomKey = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
const failure = message => new Error('Upload recovery: ' + message);
|
||||
let storageInitialized = false;
|
||||
const initializeStorage = () => {
|
||||
if (storageInitialized) return;
|
||||
// Дублирование вкладки копирует sessionStorage даже без opener. Новый
|
||||
// заход отвязывает только указатели: IndexedDB исходной вкладки не трогаем.
|
||||
if (performance.getEntriesByType('navigation')[0]?.type !== 'reload') {
|
||||
for (const name of Object.keys(sessionStorage)) {
|
||||
if (name.startsWith(prefix)) sessionStorage.removeItem(name);
|
||||
}
|
||||
}
|
||||
storageInitialized = true;
|
||||
};
|
||||
// Только ограниченный JSON: не теряем молча поля, NaN или функции при сериализации.
|
||||
const json = value => {
|
||||
const text = JSON.stringify(value, (key, item) => {
|
||||
if (item === undefined || typeof item === 'function' || typeof item === 'symbol' || typeof item === 'bigint' || (typeof item === 'number' && !Number.isFinite(item))) throw failure('invalid JSON');
|
||||
return item;
|
||||
});
|
||||
if (!text || text.length > 65536) throw failure('metadata too large');
|
||||
return JSON.parse(text);
|
||||
};
|
||||
const valid = record => {
|
||||
try {
|
||||
return record && typeof record.key === 'string' && record.key.length > 0 && record.key.length <= 200
|
||||
&& Number.isFinite(record.createdAt) && record.createdAt <= Date.now()
|
||||
&& record.expiresAt === record.createdAt + ttl && record.expiresAt > Date.now()
|
||||
&& typeof record.sourceUrl === 'string' && record.sourceUrl.length <= 16384
|
||||
&& typeof record.slot === 'string' && json(record.meta) !== undefined
|
||||
&& (record.result === null ? (record.blob === null || ((record.blob instanceof ArrayBuffer || record.blob instanceof Blob) && record.fileInfo && typeof record.fileInfo.name === 'string' && typeof record.fileInfo.type === 'string' && Number.isFinite(record.fileInfo.lastModified))) : (record.blob === null && record.fileInfo === null && json(record.result) !== undefined));
|
||||
} catch (_) { return false; }
|
||||
};
|
||||
const open = () => new Promise((resolve, reject) => {
|
||||
let request, settled = false;
|
||||
const finish = (error, db) => {
|
||||
if (settled) { if (db) db.close(); return; }
|
||||
settled = true;
|
||||
window.clearTimeout(timer);
|
||||
if (error) reject(error); else resolve(db);
|
||||
};
|
||||
const timer = window.setTimeout(() => finish(failure('open timeout')), 10000);
|
||||
try { request = indexedDB.open('zoomzilla-upload-recovery', 1); } catch (error) { finish(error); return; }
|
||||
request.onupgradeneeded = () => request.result.createObjectStore('uploads');
|
||||
request.onerror = () => finish(request.error || failure('open failed'));
|
||||
request.onblocked = () => finish(failure('database blocked'));
|
||||
request.onsuccess = () => {
|
||||
request.result.onversionchange = () => request.result.close();
|
||||
finish(null, request.result);
|
||||
};
|
||||
});
|
||||
const transaction = async callback => {
|
||||
const db = await open();
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
let tx, result, settled = false;
|
||||
try { tx = db.transaction('uploads', 'readwrite'); } catch (error) { reject(error); return; }
|
||||
const finish = error => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.clearTimeout(timer);
|
||||
if (error) reject(error); else resolve(result);
|
||||
};
|
||||
const fail = error => {
|
||||
finish(error);
|
||||
try { tx.abort(); } catch (_) { /* Транзакция могла уже завершиться. */ }
|
||||
};
|
||||
const timer = window.setTimeout(() => fail(failure('transaction timeout')), 10000);
|
||||
tx.oncomplete = () => finish();
|
||||
tx.onabort = () => finish(tx.error || failure('transaction aborted'));
|
||||
// WebKit может дать request/transaction error без последующего abort.
|
||||
tx.onerror = event => fail(event.target.error || tx.error || failure('transaction failed'));
|
||||
try {
|
||||
const store = tx.objectStore('uploads');
|
||||
// Общая уборка сирот/протухших записей, включая давно закрытые вкладки.
|
||||
const cursor = store.openCursor();
|
||||
cursor.onsuccess = () => {
|
||||
if (settled) return;
|
||||
try {
|
||||
const entry = cursor.result;
|
||||
if (!entry) return;
|
||||
if (!valid(entry.value)) entry.delete();
|
||||
entry.continue();
|
||||
} catch (error) { fail(error); }
|
||||
};
|
||||
callback(store, value => { result = value; }, fail);
|
||||
} catch (error) { fail(error); }
|
||||
});
|
||||
} finally { db.close(); }
|
||||
};
|
||||
const unpack = record => ({
|
||||
key: record.key, createdAt: record.createdAt, expiresAt: record.expiresAt,
|
||||
sourceUrl: record.sourceUrl, meta: json(record.meta), result: record.result === null ? null : json(record.result),
|
||||
// Structured clone может вернуть Blob без File-прототипа: метаданные хранятся отдельно.
|
||||
file: record.blob === null ? null : new File([record.blob], record.fileInfo.name, {type: record.fileInfo.type, lastModified: record.fileInfo.lastModified})
|
||||
});
|
||||
window.ZoomzillaUploadRecovery = {
|
||||
create({scope, target}) {
|
||||
if (typeof scope !== 'string' || !scope || typeof target !== 'string' || !target) throw failure('scope and target required');
|
||||
const slot = prefix + 'slot:' + JSON.stringify([scope, target]);
|
||||
if (!slots.has(slot)) slots.set(slot, {queue: Promise.resolve(), generation: 0});
|
||||
const state = slots.get(slot);
|
||||
const enqueue = operation => {
|
||||
const pending = state.queue.then(operation);
|
||||
state.queue = pending.catch(() => {});
|
||||
return pending;
|
||||
};
|
||||
const get = (store, id, done, fail) => {
|
||||
if (!id) { done(null); return; }
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => { try { done(request.result || null); } catch (error) { fail(error); } };
|
||||
};
|
||||
const current = record => valid(record) && record.slot === slot;
|
||||
return {
|
||||
remember(file, meta, key) {
|
||||
const generation = state.generation;
|
||||
return enqueue(async () => {
|
||||
if (generation !== state.generation) throw failure('cancelled');
|
||||
initializeStorage();
|
||||
const previous = sessionStorage.getItem(slot);
|
||||
// Неудачная новая попытка никогда не восстанавливает прежний файл.
|
||||
sessionStorage.removeItem(slot);
|
||||
const metadata = json(meta);
|
||||
if (file !== null && !(file instanceof File)) throw failure('prepared File required');
|
||||
// ArrayBuffer не зависит от поддержки Blob-файлов в IndexedDB
|
||||
// (в WebKit её отсутствие проявляется асинхронным UnknownError).
|
||||
const bytes = file === null ? null : await file.arrayBuffer();
|
||||
if (generation !== state.generation) throw failure('cancelled');
|
||||
if (key !== undefined && (typeof key !== 'string' || !key || key.length > 200)) throw failure('invalid key');
|
||||
let tab = sessionStorage.getItem(prefix + 'tab');
|
||||
if (!tab) { tab = randomKey(); sessionStorage.setItem(prefix + 'tab', tab); }
|
||||
const id = tab + ':' + randomKey();
|
||||
let record;
|
||||
await transaction((store, done, fail) => get(store, previous, old => {
|
||||
const resumed = old && old.key === key;
|
||||
if (resumed && !current(old)) throw failure('expired resume');
|
||||
const createdAt = resumed ? old.createdAt : Date.now();
|
||||
record = {slot, key: key || randomKey(), createdAt, expiresAt: createdAt + ttl,
|
||||
sourceUrl: resumed ? old.sourceUrl : location.href, meta: metadata,
|
||||
blob: bytes, fileInfo: file === null ? null : {name: file.name, type: file.type, lastModified: file.lastModified}, result: null};
|
||||
if (previous) store.delete(previous);
|
||||
store.put(record, id);
|
||||
done(null);
|
||||
}, fail));
|
||||
if (generation !== state.generation) {
|
||||
await transaction(store => store.delete(id));
|
||||
throw failure('cancelled');
|
||||
}
|
||||
try { sessionStorage.setItem(slot, id); } catch (error) {
|
||||
// Если указатель недоступен, не оставляем Blob до конца TTL.
|
||||
try { await transaction(store => store.delete(id)); } catch (_) { /* TTL уберёт сироту. */ }
|
||||
throw error;
|
||||
}
|
||||
return unpack(record);
|
||||
});
|
||||
},
|
||||
load() {
|
||||
const generation = state.generation;
|
||||
return enqueue(async () => {
|
||||
if (generation !== state.generation) return null;
|
||||
initializeStorage();
|
||||
const id = sessionStorage.getItem(slot);
|
||||
const record = await transaction((store, done, fail) => get(store, id, stored => {
|
||||
if (stored && current(stored)) { done(stored); return; }
|
||||
if (id) store.delete(id);
|
||||
done(null);
|
||||
}, fail));
|
||||
if (generation !== state.generation) return null;
|
||||
if (!record) { if (sessionStorage.getItem(slot) === id) sessionStorage.removeItem(slot); return null; }
|
||||
return unpack(record);
|
||||
});
|
||||
},
|
||||
accepted(result) {
|
||||
const generation = state.generation;
|
||||
return enqueue(async () => {
|
||||
initializeStorage();
|
||||
const receipt = json(result);
|
||||
if (receipt === null) throw failure('receipt required');
|
||||
const id = sessionStorage.getItem(slot);
|
||||
return transaction((store, done, fail) => get(store, id, record => {
|
||||
if (generation !== state.generation || !current(record)) throw failure('missing pending upload');
|
||||
record.blob = null;
|
||||
record.fileInfo = null;
|
||||
record.result = receipt;
|
||||
store.put(record, id);
|
||||
done(unpack(record));
|
||||
}, fail));
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
state.generation++;
|
||||
let id;
|
||||
try { initializeStorage(); id = sessionStorage.getItem(slot); sessionStorage.removeItem(slot); } catch (error) { return Promise.reject(error); }
|
||||
return enqueue(() => transaction(store => { if (id) store.delete(id); }));
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
})();
|
||||
JS;
|
||||
@@ -56,7 +56,7 @@
|
||||
"1": 0,
|
||||
"2": 1
|
||||
},
|
||||
"default": "1"
|
||||
"default": "2"
|
||||
},
|
||||
"focus": {
|
||||
"weights": {
|
||||
|
||||
@@ -105,7 +105,3 @@ function renderComponent(string $name, array $params = []): void
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/template.php';
|
||||
?>
|
||||
<style>
|
||||
<?= file_get_contents(__DIR__ . '/style.min.css'); ?>
|
||||
</style>
|
||||
@@ -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',
|
||||
|
||||
@@ -157,6 +157,27 @@ body {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Общий desktop-контракт плашек Index; мобильный вариант сохраняет свои размеры. */
|
||||
@media (min-width: 768px) {
|
||||
.image-compare .icv__label {
|
||||
top: 10px;
|
||||
width: 92px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
font: 400 16px/26px Inter, sans-serif;
|
||||
color: #fff;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
background: rgb(0 0 0 / 25%);
|
||||
border: 1px solid rgb(255 255 255 / 80%);
|
||||
box-shadow: none;
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-full .icv__label {
|
||||
position: absolute;
|
||||
bottom: auto;
|
||||
@@ -600,11 +621,13 @@ body {
|
||||
}
|
||||
|
||||
.navbar-toggler .navbar-toggler-icon {
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 1%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 1%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
.navbar-toggler:not(.collapsed) .navbar-toggler-icon {
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 1%29' stroke-linecap='round' stroke-width='2' d='M6 6L24 23.9999M6 24L24 6.00015'/%3e%3c/svg%3e");
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 1%29' stroke-linecap='round' stroke-width='2' d='M6 6L24 23.9999M6 24L24 6.00015'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
.theme-dark .link-icon {
|
||||
@@ -1540,6 +1563,11 @@ body {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* Пять мест сохраняются и в нулевом кадре анимации загрузки. */
|
||||
.upload-loading-dots { display: inline-grid; text-align: left; }
|
||||
.upload-loading-dots::before { content: '.....'; visibility: hidden; grid-area: 1 / 1; }
|
||||
.upload-loading-dots > span { grid-area: 1 / 1; }
|
||||
|
||||
.dropzone-form-button {
|
||||
display: inline-flex;
|
||||
padding: 0 24px;
|
||||
@@ -2745,29 +2773,169 @@ body:not(.theme-dark) .theme-light-hidden {
|
||||
}
|
||||
}
|
||||
|
||||
/* Высота с раскрытыми панелями браузера: сохраняем компактность без скачков при скролле. */
|
||||
@media (orientation: portrait), (max-aspect-ratio: 1/1) {
|
||||
|
||||
.image-examples {
|
||||
max-width: calc(100dvh / 3);
|
||||
max-width: calc(100svh / 3);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#dropzone-form {
|
||||
padding: 2dvh 2.75dvh;
|
||||
padding: 2svh 2.75svh;
|
||||
}
|
||||
|
||||
.dropzone-form-icon {
|
||||
max-height: 8dvh;
|
||||
max-height: 8svh;
|
||||
}
|
||||
|
||||
.dropzone-form-text, .image-examples-label {
|
||||
font-size: 2.45dvh;
|
||||
font-size: 2.45svh;
|
||||
}
|
||||
.dropzone-form-format-label, .dropzone-form-format-item {
|
||||
font-size: 1.8dvh;
|
||||
font-size: 1.8svh;
|
||||
}
|
||||
|
||||
.main-section {
|
||||
padding-top: 2dvh !important;
|
||||
padding-top: 2svh !important;
|
||||
}
|
||||
}
|
||||
/* api#283: утверждённый первый экран с раскрытыми панелями Яндекса. */
|
||||
@media (max-width: 767.98px) {
|
||||
.index-mobile-fit { padding-top: 8px !important; }
|
||||
.index-mobile-fit #image-compare { width: 100%; container-type: inline-size; aspect-ratio: 1696 / 920; max-height: clamp(120px, calc(100svh - 440px), 240px); }
|
||||
/* Обе стороны одного масштаба и кадрирования при ограниченной высоте. */
|
||||
.index-mobile-fit #image-compare .icv__img { position: absolute; top: 0; bottom: 0; left: 0; width: 100cqw; height: 100%; object-fit: cover; object-position: center; }
|
||||
.index-mobile-fit #image-compare .icv__img-b { left: auto; right: 0; }
|
||||
.index-mobile-fit #image-compare.native-icv img { width: 100cqw !important; }
|
||||
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) { padding: 12px; margin-top: 12px; }
|
||||
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-icon img { width: 36px; height: 36px; }
|
||||
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-text { margin-top: 6px; }
|
||||
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-format { margin-top: 8px; }
|
||||
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-action { margin-top: 10px; }
|
||||
.index-mobile-fit .image-examples { max-width: min(100%, 50svh); margin: 12px auto 0; }
|
||||
.index-mobile-fit .image-examples-label { display: none; }
|
||||
.index-mobile-fit .image-examples-list { gap: 8px; }
|
||||
.index-mobile-fit .image-examples-item { flex-basis: calc((100% - 32px) / 5); min-width: 0; }
|
||||
.index-mobile-fit #image-compare .icv__label { top: 4px; bottom: auto; margin: 0; width: 48px; height: 20px; display: flex; align-items: center; justify-content: center; box-sizing: border-box; font: 600 9px/1.3 Inter, sans-serif; padding: 2px 5px; border: 1px solid rgb(255 255 255 / 65%); border-radius: 5px; background: rgb(67 72 82 / 72%); color: #fff; box-shadow: 0 1px 3px rgb(0 0 0 / 20%); text-shadow: 0 1px 1px rgb(0 0 0 / 35%); white-space: nowrap; }
|
||||
.index-mobile-fit #image-compare .icv__label-before { left: 24px; }
|
||||
.index-mobile-fit #image-compare .icv__label-after { right: 24px; }
|
||||
}
|
||||
|
||||
/* api#284: общий контракт утверждённых шапки/подвала и мобильной обработки. */
|
||||
|
||||
.header .navbar{height:90px;padding-top:0!important;padding-bottom:0!important;}
|
||||
.header .navbar>.container{height:100%;align-items:center;flex-wrap:nowrap;}
|
||||
.header .navbar-brand{padding:0;margin-right:12px;min-width:0;flex:1 1 0;display:flex;align-items:center;}
|
||||
.header .navbar-brand img{width:240px;max-width:100%;height:auto;}
|
||||
.footer-logo img{width:160px;height:auto;max-width:100%;}
|
||||
.header .btn-header{min-height:44px;height:44px;font-size:14px;line-height:20px;padding:0 16px;border-radius:12px;white-space:nowrap;gap:6px;}
|
||||
@media(max-width:767.98px){
|
||||
.header .navbar{height:68px;} .header .navbar-brand img{width:180px;} .header .navbar-toggler{width:30px;height:30px;border-radius:8px;padding:0;display:inline-flex;align-items:center;justify-content:center;} .header .navbar-toggler-icon{width:20px;height:20px;background-size:20px;display:block;}
|
||||
.header .navbar-brand{margin-right:8px;}
|
||||
.header .btn-header{height:30px;min-height:30px;font-size:12px;padding:0 12px;border-radius:11px;}
|
||||
.header .btn-header svg{width:16px;height:16px;}
|
||||
.footer-logo img{width:130px;height:auto;}
|
||||
.site{padding-top:68px;}
|
||||
.index-mobile-fit #image-compare{max-height:clamp(120px,calc(100svh - 450px),240px);}
|
||||
}
|
||||
@media(max-width:767.98px){
|
||||
.tour-processing, .main-section:has(#process:not([style*="display: none"])) {padding-top:12px!important;padding-bottom:12px!important;min-height:0!important;}
|
||||
#process{padding:16px 12px;}
|
||||
#process .process-dimensions-unit{display:none;}
|
||||
#process .dropzone-form-inner{padding:0;}
|
||||
#process-title{margin:0;font-size:16px;line-height:24px;}
|
||||
#process-scale-note{margin:12px 0;font-size:13px;line-height:20px;text-align:center;}
|
||||
#process .dropzone-form-loading-info{margin:0;display:grid;grid-template-columns:minmax(0,1fr) 24px minmax(0,1fr);gap:4px;align-items:center;}
|
||||
#process .dropzone-form-loading-info-col{min-width:0;}
|
||||
#process .dropzone-form-loading-info-item{gap:4px;align-items:center;}
|
||||
#process .dropzone-form-loading-info-col:last-child .dropzone-form-loading-info-item{justify-content:flex-end;}
|
||||
#process .dropzone-form-loading-info-item:not(:last-child){margin-bottom:8px;}
|
||||
#process .dropzone-form-loading-info-icon svg{width:16px;height:16px;display:block;}
|
||||
#process .dropzone-form-loading-info-text{font-size:clamp(12px,3.6vw,13px);line-height:20px;white-space:nowrap;}
|
||||
#process .dropzone-form-loading-info-arrow{display:flex;justify-content:center;flex-basis:auto;opacity:.75;}
|
||||
#process .dropzone-form-loading-info-arrow svg{width:24px;}
|
||||
#process .dropzone-form-loading-progress{margin-top:16px;}
|
||||
#process .progress{height:24px;}
|
||||
#process .progress-label{font-size:12px;line-height:24px;}
|
||||
#process .process-file-line{margin-top:12px;font-size:12px;line-height:18px;}
|
||||
.tour-processing #preview, .main-section:has(#process:not([style*="display: none"])) #preview{height:clamp(220px,calc(100svh - 335px),380px)!important;margin-top:16px;}
|
||||
}
|
||||
@media(max-width:767.98px){.result-download-title{font-size:24px;line-height:1.2;}}
|
||||
|
||||
|
||||
/* api#298: общий каркас API/Preland; синхронизировать этот блок в обоих репозиториях. */
|
||||
html { scrollbar-gutter: stable; }
|
||||
body { isolation: isolate; }
|
||||
.wrapper {
|
||||
--site-header-height: 90px;
|
||||
--site-background: url(../images/bg.jpg);
|
||||
min-height: 100svh;
|
||||
background-image: none;
|
||||
}
|
||||
.theme-dark .wrapper { --site-background: url(../images/dark-bg.jpg); background-image: none; }
|
||||
.wrapper::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100lvh;
|
||||
background: var(--site-background) center / cover no-repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wrapper .main-section:not(.main-section-alt),
|
||||
.wrapper .main-section.main-section-alt2,
|
||||
.wrapper .main-content-wrapper { min-height: calc(100svh - var(--site-header-height)); }
|
||||
.wrapper .main-content-wrapper { height: auto; }
|
||||
.wrapper .main-content-wrapper > .main-section { min-height: 0; }
|
||||
@media (min-width: 768px) {
|
||||
/* 520px — общий desktop-слот Index с фото и полосой примеров при контейнере 1300px.
|
||||
Верхняя граница не зависит от короткого/длинного содержимого текущего шага. */
|
||||
.wrapper .index-mobile-fit,
|
||||
.wrapper .tour-processing {
|
||||
justify-content: flex-start;
|
||||
padding-top: max(24px, calc((100svh - var(--site-header-height) - 520px) / 2)) !important;
|
||||
}
|
||||
:is(.index-mobile-fit, .tour-processing) :is(#dropzone-form, #process) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 456px;
|
||||
}
|
||||
:is(.index-mobile-fit, .tour-processing) :is(#dropzone-form, #process) .dropzone-form-inner {
|
||||
width: 100%;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
:is(.index-mobile-fit, .tour-processing) #preview { height: auto; aspect-ratio: 1696 / 920; }
|
||||
}
|
||||
@media (max-width: 767.98px) { .wrapper { --site-header-height: 68px; } }
|
||||
/* /api#298 */
|
||||
|
||||
/* api#301: геометрия примеров существует до загрузки изображений и viewer. */
|
||||
.index-mobile-fit .dropzone-form-action { display: flex; justify-content: center; }
|
||||
.index-mobile-fit #examples { position: relative; }
|
||||
.index-mobile-fit #image-compare { position: relative; width: 100%; aspect-ratio: 1696 / 920; overflow: hidden; }
|
||||
.index-mobile-fit #image-compare > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.index-mobile-fit #examples .examples-retry { position: absolute; top: 40%; left: 50%; transform: translate(-50%, -50%); }
|
||||
.index-mobile-fit #examples .examples-retry[hidden] { display: none; }
|
||||
.index-mobile-fit #examples .image-examples-item { cursor: pointer; }
|
||||
.index-mobile-fit #examples .image-examples-item.active { border-color: #007bff; }
|
||||
/* /api#301 */
|
||||
@media (min-width: 768px) {
|
||||
/* Обе колонки занимают свои ячейки и при частично полученном HTML. */
|
||||
.index-mobile-fit > .container > .row { display: grid; grid-template-columns: 5fr 7fr; }
|
||||
.index-mobile-fit > .container > .row > .col-md-5 { grid-column: 1; grid-row: 1; max-width: none; }
|
||||
.index-mobile-fit > .container > .row > .col-md-7 { grid-column: 2; grid-row: 1; max-width: none; }
|
||||
}
|
||||
/* Разметка может прийти частями: показываем только полностью полученную форму. */
|
||||
.index-mobile-fit #dropzone-form:not(:has([data-upload-markup-ready])) { visibility: hidden; }
|
||||
@media (max-width: 767.98px) {
|
||||
/* Высоты существующего компактного вида: 243/201px контент + 24px padding. */
|
||||
html[data-upload-device=android] .index-mobile-fit #dropzone-form { min-height: 267px; }
|
||||
html[data-upload-device=mobile] .index-mobile-fit #dropzone-form { min-height: 225px; }
|
||||
html[data-upload-device=android] .index-mobile-fit .dropzone-form-action { min-height: 90px; }
|
||||
}
|
||||
@@ -756,24 +756,88 @@ border-radius: 10px;
|
||||
|
||||
@media (orientation: portrait), (max-aspect-ratio: 1/1) {
|
||||
.image-examples {
|
||||
max-width: calc(100dvh / 3);
|
||||
max-width: calc(100svh / 3);
|
||||
margin: 0 auto;
|
||||
}
|
||||
#dropzone-form {
|
||||
padding: 2dvh 2.75dvh;
|
||||
padding: 2svh 2.75svh;
|
||||
}
|
||||
.dropzone-form-icon {
|
||||
max-height: 8dvh;
|
||||
max-height: 8svh;
|
||||
}
|
||||
.dropzone-form-text,
|
||||
.image-examples-label {
|
||||
font-size: 2.45dvh;
|
||||
font-size: 2.45svh;
|
||||
}
|
||||
.dropzone-form-format-label,
|
||||
.dropzone-form-format-item {
|
||||
font-size: 1.8dvh;
|
||||
font-size: 1.8svh;
|
||||
}
|
||||
.main-section {
|
||||
padding-top: 2dvh !important;
|
||||
padding-top: 2svh !important;
|
||||
}
|
||||
}
|
||||
/* api#283: утверждённый первый экран с раскрытыми панелями Яндекса. */
|
||||
@media (max-width: 767.98px) {
|
||||
.index-mobile-fit { padding-top: 8px !important; }
|
||||
.index-mobile-fit #image-compare { width: 100%; container-type: inline-size; aspect-ratio: 1696 / 920; max-height: clamp(120px, calc(100svh - 440px), 240px); }
|
||||
/* Обе стороны одного масштаба и кадрирования при ограниченной высоте. */
|
||||
.index-mobile-fit #image-compare .icv__img { position: absolute; top: 0; bottom: 0; left: 0; width: 100cqw; height: 100%; object-fit: cover; object-position: center; }
|
||||
.index-mobile-fit #image-compare .icv__img-b { left: auto; right: 0; }
|
||||
.index-mobile-fit #image-compare.native-icv img { width: 100cqw !important; }
|
||||
.index-mobile-fit #dropzone-form[data-mobile-upload] { padding: 12px; margin-top: 12px; }
|
||||
.index-mobile-fit #dropzone-form[data-mobile-upload] .dropzone-form-icon img { width: 36px; height: 36px; }
|
||||
.index-mobile-fit #dropzone-form[data-mobile-upload] .dropzone-form-text { margin-top: 6px; }
|
||||
.index-mobile-fit #dropzone-form[data-mobile-upload] .dropzone-form-format { margin-top: 8px; }
|
||||
.index-mobile-fit #dropzone-form[data-mobile-upload] .dropzone-form-action { margin-top: 10px; }
|
||||
.index-mobile-fit .image-examples { max-width: min(100%, 50svh); margin: 12px auto 0; }
|
||||
.index-mobile-fit .image-examples-label { display: none; }
|
||||
.index-mobile-fit .image-examples-list { gap: 8px; }
|
||||
.index-mobile-fit .image-examples-item { flex-basis: calc((100% - 32px) / 5); min-width: 0; }
|
||||
.index-mobile-fit #image-compare .icv__label { top: 4px; bottom: auto; margin: 0; width: 48px; height: 20px; display: flex; align-items: center; justify-content: center; box-sizing: border-box; font: 600 9px/1.3 Inter, sans-serif; padding: 2px 5px; border: 1px solid rgb(255 255 255 / 65%); border-radius: 5px; background: rgb(67 72 82 / 72%); color: #fff; box-shadow: 0 1px 3px rgb(0 0 0 / 20%); text-shadow: 0 1px 1px rgb(0 0 0 / 35%); white-space: nowrap; }
|
||||
.index-mobile-fit #image-compare .icv__label-before { left: 24px; }
|
||||
.index-mobile-fit #image-compare .icv__label-after { right: 24px; }
|
||||
}
|
||||
|
||||
/* api#284: общий контракт утверждённых шапки/подвала и мобильной обработки. */
|
||||
|
||||
.header .navbar{height:90px;padding-top:0!important;padding-bottom:0!important;}
|
||||
.header .navbar>.container{height:100%;align-items:center;flex-wrap:nowrap;}
|
||||
.header .navbar-brand{padding:0;margin-right:12px;min-width:0;flex:1 1 0;display:flex;align-items:center;}
|
||||
.header .navbar-brand img{width:240px;max-width:100%;height:auto;}
|
||||
.footer-logo img{width:160px;height:auto;max-width:100%;}
|
||||
.header .btn-header{min-height:44px;height:44px;font-size:14px;line-height:20px;padding:0 16px;border-radius:12px;white-space:nowrap;gap:6px;}
|
||||
@media(max-width:767.98px){
|
||||
.header .navbar{height:68px;} .header .navbar-brand img{width:180px;} .header .navbar-toggler{width:30px;height:30px;border-radius:8px;padding:0;display:inline-flex;align-items:center;justify-content:center;} .header .navbar-toggler-icon{width:20px;height:20px;background-size:20px;display:block;}
|
||||
.header .navbar-brand{margin-right:8px;}
|
||||
.header .btn-header{height:30px;min-height:30px;font-size:12px;padding:0 12px;border-radius:11px;}
|
||||
.header .btn-header svg{width:16px;height:16px;}
|
||||
.footer-logo img{width:130px;height:auto;}
|
||||
.site{padding-top:68px;}
|
||||
.index-mobile-fit #image-compare{max-height:clamp(120px,calc(100svh - 450px),240px);}
|
||||
}
|
||||
@media(max-width:767.98px){
|
||||
.tour-processing, .main-section:has(#process:not([style*="display: none"])) {padding-top:12px!important;padding-bottom:12px!important;min-height:0!important;}
|
||||
#process{padding:16px 12px;}
|
||||
#process .process-dimensions-unit{display:none;}
|
||||
#process .dropzone-form-inner{padding:0;}
|
||||
#process-title{margin:0;font-size:16px;line-height:24px;}
|
||||
#process-scale-note{margin:12px 0;font-size:13px;line-height:20px;text-align:center;}
|
||||
#process .dropzone-form-loading-info{margin:0;display:grid;grid-template-columns:minmax(0,1fr) 24px minmax(0,1fr);gap:4px;align-items:center;}
|
||||
#process .dropzone-form-loading-info-col{min-width:0;}
|
||||
#process .dropzone-form-loading-info-item{gap:4px;align-items:center;}
|
||||
#process .dropzone-form-loading-info-col:last-child .dropzone-form-loading-info-item{justify-content:flex-end;}
|
||||
#process .dropzone-form-loading-info-item:not(:last-child){margin-bottom:8px;}
|
||||
#process .dropzone-form-loading-info-icon svg{width:16px;height:16px;display:block;}
|
||||
#process .dropzone-form-loading-info-text{font-size:clamp(12px,3.6vw,13px);line-height:20px;white-space:nowrap;}
|
||||
#process .dropzone-form-loading-info-arrow{display:flex;justify-content:center;flex-basis:auto;opacity:.75;}
|
||||
#process .dropzone-form-loading-info-arrow svg{width:24px;}
|
||||
#process .dropzone-form-loading-progress{margin-top:16px;}
|
||||
#process .progress{height:24px;}
|
||||
#process .progress-label{font-size:12px;line-height:24px;}
|
||||
#process .process-file-line{margin-top:12px;font-size:12px;line-height:18px;}
|
||||
.tour-processing #preview, .main-section:has(#process:not([style*="display: none"])) #preview{height:clamp(220px,calc(100svh - 335px),380px)!important;margin-top:16px;}
|
||||
}
|
||||
@media(max-width:767.98px){.result-download-title{font-size:24px;line-height:1.2;}}
|
||||
@media (min-width:768px){.image-compare .icv__label{top:10px;width:92px;height:36px;display:flex;align-items:center;justify-content:center;padding:4px 10px;font:400 16px/26px Inter,sans-serif;color:#fff;backdrop-filter:none;-webkit-backdrop-filter:none;background:rgb(0 0 0 / 25%);border:1px solid rgb(255 255 255 / 80%);box-shadow:none;text-shadow:none;}}
|
||||
.upload-loading-dots{display:inline-grid;text-align:left;}.upload-loading-dots::before{content:'.....';visibility:hidden;grid-area:1 / 1;}.upload-loading-dots > span{grid-area:1 / 1;}
|
||||
@@ -1 +1,142 @@
|
||||
.col-6,.col-md-4,.col-md-5,.col-md-7,.container{padding-right:15px;padding-left:15px;width:100%}.btn,.progress{background-color:transparent}.progress,.progress-bar{overflow:hidden;display:flex}.btn,.footer-main a,.list-unstyled a,.nav-link{text-decoration:none}*,::after,::before{box-sizing:border-box}body{margin:0;font-family:Inter,sans-serif;padding:unset!important;overflow:auto!important}.container{margin-right:auto;margin-left:auto}@media (min-width:576px){.container{max-width:540px}}@media (min-width:768px){.container{max-width:720px}.col-md-4{flex:0 0 33.333333%;max-width:33.333333%}.col-md-5{flex:0 0 41.666667%;max-width:41.666667%}.col-md-7{flex:0 0 58.333333%;max-width:58.333333%}.order-md-1{order:1}.d-md-none{display:none!important}.d-md-flex{display:flex!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.mb-md-0{margin-bottom:0!important}.text-md-right{text-align:right!important}}@media (min-width:992px){.container{max-width:960px}}@media (min-width:1200px){.container{max-width:1300px}}.row{display:flex;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.col-6,.col-md-4,.col-md-5,.col-md-7{position:relative}.col-6{flex:0 0 50%;max-width:50%}.d-none,.nav-link-auth{display:none!important}.d-flex{display:flex!important}.flex-column{flex-direction:column!important}.align-items-center{align-items:center!important}.btn,.navbar-toggler{align-items:center;cursor:pointer}.m-0{margin:0!important}.mb-0{margin-bottom:0!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mt-4{margin-top:1.5rem!important}.mr-4{margin-right:1.5rem!important}.px-0{padding-left:0!important;padding-right:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.h5{font-size:1.25rem;font-weight:500;line-height:1.2}.list-unstyled{padding-left:0;list-style:none}.list-unstyled a{color:#56ccf2}.btn{display:inline-flex;justify-content:center;font-weight:400;text-align:center;vertical-align:middle;user-select:none;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;line-height:1.5;border-radius:.25rem}.btn.disabled{opacity:1}.progress{height:1rem;font-size:.75rem;border-radius:.25rem}.progress-bar{flex-direction:column;justify-content:center;color:#fff;text-align:center;white-space:nowrap;transition:width .6s}.btn-header,.btn-header:hover{color:#990694}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:.3125rem;padding-bottom:.3125rem;white-space:nowrap}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.collapse:not(.show),.footer-logo img:nth-child(2),.navbar-brand img:nth-child(2),.theme-dark .footer-logo img:first-child,.theme-dark .navbar-brand img:first-child{display:none}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem}.wrapper{display:flex;flex-direction:column;min-height:100vh;background:url(../images/bg.jpg) center/cover no-repeat}.header,.header .navbar-collapse{background-color:rgba(255,255,255,.6)}.site{flex-grow:1;padding-top:90px}.main-section{display:flex;flex-direction:column;justify-content:center;min-height:calc(100vh - 88px)}.header{position:absolute;top:0;left:0;width:100%;z-index:60}.dropzone-form-loading-progress .progress-label,.image-loading-error,.image-loading-icon,.image-loading-icon img.sandglass{top:50%;left:50%;transform:translate(-50%,-50%)}.header .navbar{border-bottom:1px solid #e5e5e7}.btn-header{gap:8px;border:1px solid #990694;border-radius:16px;font-size:16px;padding:.9rem 24px}.navbar-toggler{border-radius:16px;width:56px;height:56px;background:linear-gradient(to bottom,#3d87e3,#990694);color:#f9f9f9;border:none;display:inline-flex;justify-content:center}.navbar-toggler .navbar-toggler-icon{width:30px;height:30px;display:inline-block;background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 1%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") center/contain no-repeat}.navbar-toggler:not(.collapsed) .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 1%29' stroke-linecap='round' stroke-width='2' d='M6 6L24 23.9999M6 24L24 6.00015'/%3e%3c/svg%3e")}.header-navbar{max-width:448px;margin-left:auto;padding-top:8px;padding-bottom:24px}.header-navbar .nav-link{color:currentColor;transition:.2s;background-color:transparent!important;display:flex;align-items:center;gap:8px}.header-navbar .nav-link span{flex-grow:1}.drLine truncated
|
||||
.col-6,.col-md-4,.col-md-5,.col-md-7,.container{padding-right:15px;padding-left:15px;width:100%}.btn,.progress{background-color:transparent}.progress,.progress-bar{overflow:hidden;display:flex}.btn,.footer-main a,.list-unstyled a,.nav-link{text-decoration:none}*,::after,::before{box-sizing:border-box}body{margin:0;font-family:Inter,sans-serif;padding:unset!important;overflow:auto!important}.container{margin-right:auto;margin-left:auto}@media (min-width:576px){.container{max-width:540px}}@media (min-width:768px){.container{max-width:720px}.col-md-4{flex:0 0 33.333333%;max-width:33.333333%}.col-md-5{flex:0 0 41.666667%;max-width:41.666667%}.col-md-7{flex:0 0 58.333333%;max-width:58.333333%}.order-md-1{order:1}.d-md-none{display:none!important}.d-md-flex{display:flex!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.mb-md-0{margin-bottom:0!important}.text-md-right{text-align:right!important}}@media (min-width:992px){.container{max-width:960px}}@media (min-width:1200px){.container{max-width:1300px}}.row{display:flex;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.col-6,.col-md-4,.col-md-5,.col-md-7{position:relative}.col-6{flex:0 0 50%;max-width:50%}.d-none,.nav-link-auth{display:none!important}.d-flex{display:flex!important}.flex-column{flex-direction:column!important}.align-items-center{align-items:center!important}.btn,.navbar-toggler{align-items:center;cursor:pointer}.m-0{margin:0!important}.mb-0{margin-bottom:0!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mt-4{margin-top:1.5rem!important}.mr-4{margin-right:1.5rem!important}.px-0{padding-left:0!important;padding-right:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.h5{font-size:1.25rem;font-weight:500;line-height:1.2}.list-unstyled{padding-left:0;list-style:none}.list-unstyled a{color:#56ccf2}.btn{display:inline-flex;justify-content:center;font-weight:400;text-align:center;vertical-align:middle;user-select:none;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;line-height:1.5;border-radius:.25rem}.btn.disabled{opacity:1}.progress{height:1rem;font-size:.75rem;border-radius:.25rem}.progress-bar{flex-direction:column;justify-content:center;color:#fff;text-align:center;white-space:nowrap;transition:width .6s}.btn-header,.btn-header:hover{color:#990694}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:.3125rem;padding-bottom:.3125rem;white-space:nowrap}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.collapse:not(.show),.footer-logo img:nth-child(2),.navbar-brand img:nth-child(2),.theme-dark .footer-logo img:first-child,.theme-dark .navbar-brand img:first-child{display:none}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem}.wrapper{display:flex;flex-direction:column;min-height:100vh;background:url(../images/bg.jpg) center/cover no-repeat}.header,.header .navbar-collapse{background-color:rgba(255,255,255,.6)}.site{flex-grow:1;padding-top:90px}.main-section{display:flex;flex-direction:column;justify-content:center;min-height:calc(100vh - 88px)}.header{position:absolute;top:0;left:0;width:100%;z-index:60}.dropzone-form-loading-progress .progress-label,.image-loading-error,.image-loading-icon,.image-loading-icon img.sandglass{top:50%;left:50%;transform:translate(-50%,-50%)}.header .navbar{border-bottom:1px solid #e5e5e7}.btn-header{gap:8px;border:1px solid #990694;border-radius:16px;font-size:16px;padding:.9rem 24px}.navbar-toggler{border-radius:16px;width:56px;height:56px;background:linear-gradient(to bottom,#3d87e3,#990694);color:#f9f9f9;border:none;display:inline-flex;justify-content:center}.navbar-toggler .navbar-toggler-icon{width:30px;height:30px;display:inline-block;background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 1%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") center/contain no-repeat}.navbar-toggler:not(.collapsed) .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 1%29' stroke-linecap='round' stroke-width='2' d='M6 6L24 23.9999M6 24L24 6.00015'/%3e%3c/svg%3e")}.header-navbar{max-width:448px;margin-left:auto;padding-top:8px;padding-bottom:24px}.header-navbar .nav-link{color:currentColor;transition:.2s;background-color:transparent!important;display:flex;align-items:center;gap:8px}.header-navbar .nav-link span{flex-grow:1}.drLine truncated
|
||||
/* api#283: утверждённый первый экран с раскрытыми панелями Яндекса. */
|
||||
@media (max-width: 767.98px) {
|
||||
.index-mobile-fit { padding-top: 8px !important; }
|
||||
.index-mobile-fit #image-compare { width: 100%; container-type: inline-size; aspect-ratio: 1696 / 920; max-height: clamp(120px, calc(100svh - 440px), 240px); }
|
||||
/* Обе стороны одного масштаба и кадрирования при ограниченной высоте. */
|
||||
.index-mobile-fit #image-compare .icv__img { position: absolute; top: 0; bottom: 0; left: 0; width: 100cqw; height: 100%; object-fit: cover; object-position: center; }
|
||||
.index-mobile-fit #image-compare .icv__img-b { left: auto; right: 0; }
|
||||
.index-mobile-fit #image-compare.native-icv img { width: 100cqw !important; }
|
||||
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) { padding: 12px; margin-top: 12px; }
|
||||
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-icon img { width: 36px; height: 36px; }
|
||||
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-text { margin-top: 6px; }
|
||||
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-format { margin-top: 8px; }
|
||||
.index-mobile-fit :is(#dropzone-form[data-mobile-upload], html[data-upload-device="mobile"] #dropzone-form, html[data-upload-device="android"] #dropzone-form) .dropzone-form-action { margin-top: 10px; }
|
||||
.index-mobile-fit .image-examples { max-width: min(100%, 50svh); margin: 12px auto 0; }
|
||||
.index-mobile-fit .image-examples-label { display: none; }
|
||||
.index-mobile-fit .image-examples-list { gap: 8px; }
|
||||
.index-mobile-fit .image-examples-item { flex-basis: calc((100% - 32px) / 5); min-width: 0; }
|
||||
.index-mobile-fit #image-compare .icv__label { top: 4px; bottom: auto; margin: 0; width: 48px; height: 20px; display: flex; align-items: center; justify-content: center; box-sizing: border-box; font: 600 9px/1.3 Inter, sans-serif; padding: 2px 5px; border: 1px solid rgb(255 255 255 / 65%); border-radius: 5px; background: rgb(67 72 82 / 72%); color: #fff; box-shadow: 0 1px 3px rgb(0 0 0 / 20%); text-shadow: 0 1px 1px rgb(0 0 0 / 35%); white-space: nowrap; }
|
||||
.index-mobile-fit #image-compare .icv__label-before { left: 24px; }
|
||||
.index-mobile-fit #image-compare .icv__label-after { right: 24px; }
|
||||
}
|
||||
|
||||
/* api#284: общий контракт утверждённых шапки/подвала и мобильной обработки. */
|
||||
|
||||
.header .navbar{height:90px;padding-top:0!important;padding-bottom:0!important;}
|
||||
.header .navbar>.container{height:100%;align-items:center;flex-wrap:nowrap;}
|
||||
.header .navbar-brand{padding:0;margin-right:12px;min-width:0;flex:1 1 0;display:flex;align-items:center;}
|
||||
.header .navbar-brand img{width:240px;max-width:100%;height:auto;}
|
||||
.footer-logo img{width:160px;height:auto;max-width:100%;}
|
||||
.header .btn-header{min-height:44px;height:44px;font-size:14px;line-height:20px;padding:0 16px;border-radius:12px;white-space:nowrap;gap:6px;}
|
||||
@media(max-width:767.98px){
|
||||
.header .navbar{height:68px;} .header .navbar-brand img{width:180px;} .header .navbar-toggler{width:30px;height:30px;border-radius:8px;padding:0;display:inline-flex;align-items:center;justify-content:center;} .header .navbar-toggler-icon{width:20px;height:20px;background-size:20px;display:block;}
|
||||
.header .navbar-brand{margin-right:8px;}
|
||||
.header .btn-header{height:30px;min-height:30px;font-size:12px;padding:0 12px;border-radius:11px;}
|
||||
.header .btn-header svg{width:16px;height:16px;}
|
||||
.footer-logo img{width:130px;height:auto;}
|
||||
.site{padding-top:68px;}
|
||||
.index-mobile-fit #image-compare{max-height:clamp(120px,calc(100svh - 450px),240px);}
|
||||
}
|
||||
@media(max-width:767.98px){
|
||||
.tour-processing, .main-section:has(#process:not([style*="display: none"])) {padding-top:12px!important;padding-bottom:12px!important;min-height:0!important;}
|
||||
#process{padding:16px 12px;}
|
||||
#process .process-dimensions-unit{display:none;}
|
||||
#process .dropzone-form-inner{padding:0;}
|
||||
#process-title{margin:0;font-size:16px;line-height:24px;}
|
||||
#process-scale-note{margin:12px 0;font-size:13px;line-height:20px;text-align:center;}
|
||||
#process .dropzone-form-loading-info{margin:0;display:grid;grid-template-columns:minmax(0,1fr) 24px minmax(0,1fr);gap:4px;align-items:center;}
|
||||
#process .dropzone-form-loading-info-col{min-width:0;}
|
||||
#process .dropzone-form-loading-info-item{gap:4px;align-items:center;}
|
||||
#process .dropzone-form-loading-info-col:last-child .dropzone-form-loading-info-item{justify-content:flex-end;}
|
||||
#process .dropzone-form-loading-info-item:not(:last-child){margin-bottom:8px;}
|
||||
#process .dropzone-form-loading-info-icon svg{width:16px;height:16px;display:block;}
|
||||
#process .dropzone-form-loading-info-text{font-size:clamp(12px,3.6vw,13px);line-height:20px;white-space:nowrap;}
|
||||
#process .dropzone-form-loading-info-arrow{display:flex;justify-content:center;flex-basis:auto;opacity:.75;}
|
||||
#process .dropzone-form-loading-info-arrow svg{width:24px;}
|
||||
#process .dropzone-form-loading-progress{margin-top:16px;}
|
||||
#process .progress{height:24px;}
|
||||
#process .progress-label{font-size:12px;line-height:24px;}
|
||||
#process .process-file-line{margin-top:12px;font-size:12px;line-height:18px;}
|
||||
.tour-processing #preview, .main-section:has(#process:not([style*="display: none"])) #preview{height:clamp(220px,calc(100svh - 335px),380px)!important;margin-top:16px;}
|
||||
}
|
||||
@media(max-width:767.98px){.result-download-title{font-size:24px;line-height:1.2;}}
|
||||
@media (min-width:768px){.image-compare .icv__label{top:10px;width:92px;height:36px;display:flex;align-items:center;justify-content:center;padding:4px 10px;font:400 16px/26px Inter,sans-serif;color:#fff;backdrop-filter:none;-webkit-backdrop-filter:none;background:rgb(0 0 0 / 25%);border:1px solid rgb(255 255 255 / 80%);box-shadow:none;text-shadow:none;}}
|
||||
.upload-loading-dots{display:inline-grid;text-align:left;}.upload-loading-dots::before{content:'.....';visibility:hidden;grid-area:1 / 1;}.upload-loading-dots > span{grid-area:1 / 1;}
|
||||
|
||||
|
||||
/* api#298: общий каркас API/Preland; синхронизировать этот блок в обоих репозиториях. */
|
||||
html { scrollbar-gutter: stable; }
|
||||
body { isolation: isolate; }
|
||||
.wrapper {
|
||||
--site-header-height: 90px;
|
||||
--site-background: url(../images/bg.jpg);
|
||||
min-height: 100svh;
|
||||
background-image: none;
|
||||
}
|
||||
.theme-dark .wrapper { --site-background: url(../images/dark-bg.jpg); background-image: none; }
|
||||
.wrapper::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100lvh;
|
||||
background: var(--site-background) center / cover no-repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wrapper .main-section:not(.main-section-alt),
|
||||
.wrapper .main-section.main-section-alt2,
|
||||
.wrapper .main-content-wrapper { min-height: calc(100svh - var(--site-header-height)); }
|
||||
.wrapper .main-content-wrapper { height: auto; }
|
||||
.wrapper .main-content-wrapper > .main-section { min-height: 0; }
|
||||
@media (min-width: 768px) {
|
||||
/* 520px — общий desktop-слот Index с фото и полосой примеров при контейнере 1300px.
|
||||
Верхняя граница не зависит от короткого/длинного содержимого текущего шага. */
|
||||
.wrapper .index-mobile-fit,
|
||||
.wrapper .tour-processing {
|
||||
justify-content: flex-start;
|
||||
padding-top: max(24px, calc((100svh - var(--site-header-height) - 520px) / 2)) !important;
|
||||
}
|
||||
:is(.index-mobile-fit, .tour-processing) :is(#dropzone-form, #process) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 456px;
|
||||
}
|
||||
:is(.index-mobile-fit, .tour-processing) :is(#dropzone-form, #process) .dropzone-form-inner {
|
||||
width: 100%;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
:is(.index-mobile-fit, .tour-processing) #preview { height: auto; aspect-ratio: 1696 / 920; }
|
||||
}
|
||||
@media (max-width: 767.98px) { .wrapper { --site-header-height: 68px; } }
|
||||
/* /api#298 */
|
||||
|
||||
/* api#301: геометрия примеров существует до загрузки изображений и viewer. */
|
||||
.index-mobile-fit .dropzone-form-action { display: flex; justify-content: center; }
|
||||
.index-mobile-fit #examples { position: relative; }
|
||||
.index-mobile-fit #image-compare { position: relative; width: 100%; aspect-ratio: 1696 / 920; overflow: hidden; }
|
||||
.index-mobile-fit #image-compare > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.index-mobile-fit #examples .examples-retry { position: absolute; top: 40%; left: 50%; transform: translate(-50%, -50%); }
|
||||
.index-mobile-fit #examples .examples-retry[hidden] { display: none; }
|
||||
.index-mobile-fit #examples .image-examples-item { cursor: pointer; }
|
||||
.index-mobile-fit #examples .image-examples-item.active { border-color: #007bff; }
|
||||
/* /api#301 */
|
||||
@media (min-width: 768px) {
|
||||
/* Обе колонки занимают свои ячейки и при частично полученном HTML. */
|
||||
.index-mobile-fit > .container > .row { display: grid; grid-template-columns: 5fr 7fr; }
|
||||
.index-mobile-fit > .container > .row > .col-md-5 { grid-column: 1; grid-row: 1; max-width: none; }
|
||||
.index-mobile-fit > .container > .row > .col-md-7 { grid-column: 2; grid-row: 1; max-width: none; }
|
||||
}
|
||||
/* Разметка может прийти частями: показываем только полностью полученную форму. */
|
||||
.index-mobile-fit #dropzone-form:not(:has([data-upload-markup-ready])) { visibility: hidden; }
|
||||
@media (max-width: 767.98px) {
|
||||
/* Высоты существующего компактного вида: 243/201px контент + 24px padding. */
|
||||
html[data-upload-device=android] .index-mobile-fit #dropzone-form { min-height: 267px; }
|
||||
html[data-upload-device=mobile] .index-mobile-fit #dropzone-form { min-height: 225px; }
|
||||
html[data-upload-device=android] .index-mobile-fit .dropzone-form-action { min-height: 90px; }
|
||||
}
|
||||
@@ -47,7 +47,9 @@
|
||||
<link rel="preload" as="image" href="<?= $urlSrcOrig ?>" media="(min-width: 768px)" type="image/avif" fetchpriority="high">
|
||||
<link rel="preload" as="image" href="<?= $urlResOrig ?>" media="(min-width: 768px)" type="image/avif" fetchpriority="high">
|
||||
|
||||
<!-- <link rel="stylesheet" href="style.min.css"> -->
|
||||
<style><?= file_get_contents(__DIR__ . '/style.min.css'); ?></style>
|
||||
<style><?= require __DIR__ . '/components/upload-picker-style.php'; ?></style>
|
||||
<?php require_once __DIR__ . '/components/browser-upload.php'; ?>
|
||||
</head>
|
||||
<body class="<?= (isset($UTM['theme']) && $UTM['theme'] == 'dark') ? 'theme-dark' : '' ?>" style="<?= (isset($UTM['theme']) && $UTM['theme'] == 'dark') ? 'background: black' : '' ?>">
|
||||
<div class="wrapper">
|
||||
@@ -72,7 +74,7 @@
|
||||
|
||||
</header>
|
||||
<main class="site">
|
||||
<div class="py-4 main-section">
|
||||
<div class="py-4 main-section index-mobile-fit">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
|
||||
@@ -60,7 +60,11 @@ foreach ([
|
||||
}
|
||||
}
|
||||
|
||||
if (substr_count($workflow, 'while [ -n "$dir" ]; do') < 2) {
|
||||
if (!str_contains($workflow, '|[0-9]+/)')) {
|
||||
$failures[] = 'Stage-деплой не должен перезаписывать корневые наборы /N/: stage владеет их содержимым.';
|
||||
}
|
||||
|
||||
if (substr_count($workflow, 'while [ -n "$dir" ]; do') < 1) {
|
||||
$failures[] = 'SFTP-деплой должен создавать все родительские каталоги для новых наборов assets.';
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ if (!is_string($query)) {
|
||||
}
|
||||
|
||||
parse_str($query, $params);
|
||||
$config = json_decode((string) file_get_contents(dirname(__DIR__) . '/config.json'), true);
|
||||
$expected = [
|
||||
'indpic' => '1',
|
||||
'theme' => 'dark',
|
||||
@@ -37,7 +38,7 @@ $expected = [
|
||||
'registration' => 'after',
|
||||
'bchoice' => 'a',
|
||||
'chb' => 'b',
|
||||
'tp' => '1',
|
||||
'tp' => $config['utm']['tp']['default'] ?? null,
|
||||
'focus' => 'face',
|
||||
'utm_source' => 'stage-ad',
|
||||
'subid' => 'click-42',
|
||||
|
||||
@@ -17,56 +17,39 @@ $config = is_file($configPath) ? json_decode((string) file_get_contents($configP
|
||||
if (!is_array($config)) {
|
||||
$failures[] = 'Рабочий config.json должен существовать в корне приленда и содержать валидный JSON.';
|
||||
} else {
|
||||
$expected = [
|
||||
'host' => 'zoomzilla.ru',
|
||||
'indpic' => ['1' => 1, '2' => 0, 'default' => '1'],
|
||||
'theme' => ['light' => 0, 'dark' => 1, 'default' => 'dark'],
|
||||
'degr' => ['1' => 1, '2' => 0, 'default' => '1'],
|
||||
'registration' => ['before' => 0, 'after' => 1, 'default' => 'after'],
|
||||
'bchoice' => ['a' => 1, 'b' => 0, 'default' => 'a'],
|
||||
'chb' => ['a' => 1, 'b' => 0, 'default' => 'b'],
|
||||
'tp' => ['1' => 0, '2' => 1, 'default' => '1'],
|
||||
'focus' => ['center' => 0, 'face' => 1, 'default' => 'face'],
|
||||
];
|
||||
|
||||
if (($config['host'] ?? null) !== $expected['host']) {
|
||||
if (($config['host'] ?? null) !== 'zoomzilla.ru') {
|
||||
$failures[] = 'Рабочий config.json обязан направлять загрузку на zoomzilla.ru.';
|
||||
}
|
||||
|
||||
foreach (array_diff_key($expected, ['host' => true]) as $key => $settings) {
|
||||
$actual = $config['utm'][$key] ?? [];
|
||||
if (($actual['weights'] ?? null) !== array_diff_key($settings, ['default' => true])
|
||||
|| ($actual['default'] ?? null) !== $settings['default']) {
|
||||
$failures[] = "Рабочий config.json должен сохранять stage-настройку UTM {$key}.";
|
||||
$focus = $config['utm']['focus'] ?? null;
|
||||
if (($focus['weights'] ?? null) !== ['center' => 0, 'face' => 1]
|
||||
|| ($focus['default'] ?? null) !== 'face') {
|
||||
$failures[] = 'Рабочий config.json должен фиксировать явно согласованный focus=face.';
|
||||
}
|
||||
|
||||
foreach (['indpic', 'theme', 'degr', 'registration', 'bchoice', 'chb', 'tp'] as $key) {
|
||||
$setting = $config['utm'][$key] ?? null;
|
||||
if (!is_array($setting) || !is_array($setting['weights'] ?? null) || !is_string($setting['default'] ?? null)) {
|
||||
$failures[] = "Рабочий config.json должен хранить валидный stage-снимок UTM {$key}.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$workflow = (string) file_get_contents($root . '/.gitea/workflows/deploy.yml');
|
||||
$legacyStart = strpos($workflow, ' deploy:');
|
||||
$stageStart = strpos($workflow, ' deploy-zoomstage:');
|
||||
$productionStart = strpos($workflow, 'deploy-zoompixel-production:');
|
||||
$legacyBlock = $legacyStart === false || $stageStart === false
|
||||
? ''
|
||||
: substr($workflow, $legacyStart, $stageStart - $legacyStart);
|
||||
$stageBlock = $stageStart === false || $productionStart === false
|
||||
? ''
|
||||
: substr($workflow, $stageStart, $productionStart - $stageStart);
|
||||
$productionBlock = $productionStart === false ? '' : substr($workflow, $productionStart);
|
||||
|
||||
foreach ([
|
||||
'legacy FTP' => $legacyBlock,
|
||||
'deploy-zoomstage' => $stageBlock,
|
||||
'deploy-zoompixel-production' => $productionBlock,
|
||||
] as $delivery => $block) {
|
||||
if ($block === ''
|
||||
|| !str_contains($block, 'config\\.json$')
|
||||
|| !str_contains($block, 'ensure-focus-config.php')
|
||||
|| !str_contains($block, 'Preserve host config and add focus if absent')) {
|
||||
$failures[] = "{$delivery} обязан сохранять хостовый config.json и менять только явно согласованный focus.";
|
||||
$stageBlock = $stageStart === false ? '' : substr($workflow, $stageStart);
|
||||
if ($stageBlock === '' || !str_contains($stageBlock, 'config\\.json$')
|
||||
|| !str_contains($stageBlock, 'ensure-focus-config.php')
|
||||
|| !str_contains($stageBlock, 'Preserve host config and add focus if absent')) {
|
||||
$failures[] = 'Stage обязан сохранять хостовый config.json и согласованный focus-контракт.';
|
||||
}
|
||||
if (preg_match_all('/^ [a-z][a-z0-9-]*:/m', substr($workflow, strpos($workflow, 'jobs:')), $jobs) !== 1
|
||||
|| !str_contains($workflow, " - staging")
|
||||
|| str_contains($workflow, " - main")
|
||||
|| str_contains($workflow, 'deploy-zoompixel-production:')
|
||||
|| str_contains($workflow, 'barbariskatest')) {
|
||||
$failures[] = 'По решению владельца автодеплой должен содержать только staging/zoomstage.online.';
|
||||
}
|
||||
|
||||
if (is_file($root . '/config.json.example')) {
|
||||
$failures[] = 'config.json.example не должен существовать: рабочий config.json единственный.';
|
||||
}
|
||||
@@ -76,4 +59,4 @@ if ($failures !== []) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
fwrite(STDOUT, "OK: versioned stage config and ZoomPixel delivery\n");
|
||||
fwrite(STDOUT, "OK: host config preserved; deploy targets stage only\n");
|
||||
@@ -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");
|
||||
|
||||