From 9835bb0ef8235572a2dd33b180bf97de39823a6e Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sat, 17 Oct 2026 10:56:08 +0300 Subject: [PATCH 01/19] feat(utm): propagate join focus mode --- README.md | 27 +++++++----- config.json | 7 +++ tests/check-utm-redirect-contract.php | 59 ++++++++++++++++++++++++++ tests/check-versioned-stage-config.php | 1 + 4 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 tests/check-utm-redirect-contract.php diff --git a/README.md b/README.md index bd6c7d9..5da2c52 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,11 @@ - `rnd` — случайное значение по весам (`weights`), так делится трафик между вариантами; - неизвестное значение отбрасывается, отсутствующее — заменяется на `default`. -Ненастроенные параметры (`utm_source`, `keyword`, `subid` и любые другие) проходят как есть. Все метки уходят дальше -в адрес сайта: сайт сохраняет их на воронку и шлёт в Keitaro. +Ненастроенные параметры (`utm_source`, `keyword`, `subid` и любые другие) проходят как есть. Перед переходом +Preland всегда формирует полный resolved-набор своих восьми меток: явное допустимое значение остаётся, `rnd` +разрешается по весам, отсутствующее получает `default`. Вместе с внешними рекламными параметрами этот набор +уходит в `claim`, а затем сайт переносит его в `upscale`; ни один из восьми параметров не должен пропадать только +потому, что был назначен default. | Метка | Что задаёт | Где | |---|---|---| @@ -30,6 +33,7 @@ | `bchoice` | попап выбора банка (`a` — показать) | сайт, оплата (#44) | | `chb` | галочки согласия на странице оплаты (`no` — не обязательны) | сайт, оплата | | `tp` | группа тарифов | сайт, cpanel «Группы тарифов (tp)» | +| `focus` | первоначальная область лупы Join: `center`/`face` | сайт, cpanel после `tp` | ### Загрузка и переход на сайт @@ -61,7 +65,7 @@ | `requisites.html` | реквизиты для подвала (HTML), если в `config.json` нет `requisites`. Без обоих — **заглушка «ИП Иванов Иван Иванович»** из `template.php` | | `images/`, `fonts/`, `favicon/`, `*.css` | статика; `style.min.css` встраивается в страницу | | `.htaccess` | только сроки кэширования статики (Apache) | -| `config.json` | единственный версионируемый рабочий конфиг всех прилендов: точная проверенная конфигурация stage `zoomstage.online`; каждый deploy доставляет его на целевой хост | +| `config.json` | зафиксированный Git-снимок актуального stage-конфига; runtime-файл хоста является источником истины и обычным deploy не заменяется | ### `config.json` @@ -87,10 +91,10 @@ 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. **Конфиг и реквизиты.** Runtime `config.json` создаётся при первичной настройке хоста и не заменяется + обычной синхронизацией. Менять в нём можно только ключи, явно согласованные в задаче; после такого выпуска + актуальный stage-снимок фиксируется в Git. Реквизиты задаются ключом `requisites` либо runtime-файлом + `requisites.html`. 4. **nginx без Apache** (если хостинг не обрабатывает `.htaccess`): ```nginx server { @@ -137,12 +141,13 @@ 1. Домен и HTTPS — как в «Деплой на произвольный домен», шаг 1. 2. Секреты `<ПРЕФИКС>_FTP_HOST`, `<ПРЕФИКС>_FTP_USER`, `<ПРЕФИКС>_FTP_PASS` (Gitea → настройки репозитория → Actions → секреты; порт 21, FTP с TLS). В коде реквизитов нет. -3. Никаких ручных конфигов не создавать: единственный `config.json` приезжает вместе с кодом. -4. Запись в `matrix` — коммит в `main`, автодеплой выкатит все цели вместе с конфигом. +3. Один раз создать runtime `config.json` с нужными значениями: последующие обычные deploy его не перезапишут. +4. Запись в `matrix` — коммит в `main`, автодеплой выкатит код на все цели, сохраняя их конфиги. **Что заливается.** Каждый deploy получает файлы репозитория (`git ls-files`), кроме служебных -(`.gitea/`, `README.md`, `.gitignore`) и живущего на хостинге `requisites.html`. Единственный -версионируемый `config.json` всегда доставляется вместе с кодом. +(`.gitea/`, `README.md`, `.gitignore`), а также живущих на хостинге `requisites.html` и `config.json`. +Перед разрешённым изменением конкретного конфигурационного ключа workflow считывает текущий host-файл и +патчит только этот ключ. На хостинге ничего не удаляется: файлы, убранные из репозитория, удалять вручную. **Проверка после заливки.** На странице цели должен быть адрес загрузки на сайте, а не на самом преленде (иначе нет diff --git a/config.json b/config.json index 62063b9..c31a200 100644 --- a/config.json +++ b/config.json @@ -57,6 +57,13 @@ "2": 1 }, "default": "1" + }, + "focus": { + "weights": { + "center": 1, + "face": 0 + }, + "default": "center" } } } diff --git a/tests/check-utm-redirect-contract.php b/tests/check-utm-redirect-contract.php new file mode 100644 index 0000000..a068466 --- /dev/null +++ b/tests/check-utm-redirect-contract.php @@ -0,0 +1,59 @@ + 'stage-ad', + 'subid' => 'click-42', + 'key' => 'must-not-replace-client-key', +]; +$_SERVER['HTTP_HOST'] = 'zoomstage.online'; + +ob_start(); +require dirname(__DIR__) . '/index.php'; +$html = (string) ob_get_clean(); + +if (!preg_match('/const redirectUrlUtm = (.+);/', $html, $match)) { + fwrite(STDERR, "FAIL: Preland не отдал redirectUrlUtm.\n"); + exit(1); +} + +$query = json_decode($match[1], true); +if (!is_string($query)) { + fwrite(STDERR, "FAIL: redirectUrlUtm должен быть JSON-строкой.\n"); + exit(1); +} + +parse_str($query, $params); +$expected = [ + 'indpic' => '1', + 'theme' => 'dark', + 'degr' => '1', + 'registration' => 'after', + 'bchoice' => 'a', + 'chb' => 'b', + 'tp' => '1', + 'focus' => 'center', + 'utm_source' => 'stage-ad', + 'subid' => 'click-42', +]; + +ksort($params); +ksort($expected); + +if ($params !== $expected) { + fwrite(STDERR, "FAIL: claim URL обязан нести все resolved UTM и внешние параметры.\n"); + exit(1); +} + +if (isset($params['key'])) { + fwrite(STDERR, "FAIL: внешний key не должен подменять одноразовый ключ загрузки.\n"); + exit(1); +} + +fwrite(STDOUT, "OK: preland redirects resolved UTM and external parameters\n"); diff --git a/tests/check-versioned-stage-config.php b/tests/check-versioned-stage-config.php index 6664953..dc04f3d 100644 --- a/tests/check-versioned-stage-config.php +++ b/tests/check-versioned-stage-config.php @@ -25,6 +25,7 @@ if (!is_array($config)) { 'bchoice' => ['a' => 1, 'b' => 0, 'default' => 'a'], 'chb' => ['a' => 1, 'b' => 0, 'default' => 'b'], 'tp' => ['1' => 0, '2' => 1, 'default' => '1'], + 'focus' => ['center' => 1, 'face' => 0, 'default' => 'center'], ]; if (($config['host'] ?? null) !== $expected['host']) { From f14ce88cc65035da747aa97cf7cdb48e0926ebe0 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sat, 17 Oct 2026 19:17:56 +0300 Subject: [PATCH 02/19] fix(deploy): preserve host preland config --- .gitea/workflows/deploy.yml | 88 ++++++++++++++++++++++++-- AGENTS.md | 11 ++-- bin/ensure-focus-config.php | 68 ++++++++++++++++++++ config.json | 6 +- tests/check-utm-redirect-contract.php | 2 +- tests/check-versioned-stage-config.php | 14 ++-- 6 files changed, 169 insertions(+), 20 deletions(-) create mode 100644 bin/ensure-focus-config.php diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 6e00daa..f787899 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,9 @@ name: deploy-preland # _FTP_USER, _FTP_PASS (порт 21). Цели независимы: сбой одного хостинга остальные не останавливает. # Заливаются файлы репозитория (git ls-files), кроме служебных (.gitea/, README.md, AGENTS.md, CLAUDE.md, .gitignore) и тех, что живут # только на хостинге: requisites.html (реквизиты заполняют на месте — пустой файл из git их бы затёр). -# Рабочий config.json единственный и версионируется: он должен попадать в каждую доставку. +# Рабочий config.json настраивается владельцем на каждом хостинге и является +# источником истины. Обычный deploy его не перезаписывает; задача может менять +# только прямо согласованные ключи после чтения текущего host-файла. # На хостинге ничего не удаляется: удалённое из репозитория убирать вручную. on: @@ -43,11 +45,35 @@ jobs: steps: - name: Install tools (Alpine) - run: apk add --no-cache git curl + 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)] }} @@ -62,7 +88,7 @@ jobs: 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$)' > files.txt + 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 вместо соединения на файл @@ -99,11 +125,35 @@ jobs: REMOTE_DIR: domains/zoomstage.online 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 + 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 staging branch run: git clone --depth 1 --branch staging 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 }} @@ -119,7 +169,7 @@ jobs: files=$(mktemp) trap 'rm -f "$known_hosts" "$batch" "$files"' EXIT printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts" - git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$)' > "$files" + 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" sed 's#/[^/]*$##' "$files" | grep -v '^$' | sort -u | while read -r dir; do printf '%s\n' "-mkdir $REMOTE_DIR/$dir" >> "$batch"; done @@ -158,11 +208,35 @@ jobs: 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 + 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 }} @@ -178,7 +252,7 @@ jobs: files=$(mktemp) trap 'rm -f "$known_hosts" "$batch" "$files"' EXIT printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts" - git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$)' > "$files" + 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" sed 's#/[^/]*$##' "$files" | grep -v '^$' | sort -u | while read -r dir; do printf '%s\n' "-mkdir $REMOTE_DIR/$dir" >> "$batch"; done diff --git a/AGENTS.md b/AGENTS.md index 3abb684..38ff8b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,10 +21,13 @@ переход напрямую на `/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 + получает текущий хостовый конфиг и добавляет только отсутствующий ключ через + `bin/ensure-focus-config.php`; имеющиеся значения, включая уже настроенный + `focus`, остаются байт-в-байт прежними. `requisites.html` также живёт только на + хостинге и не перезаписывается. Не создавать target-specific конфиги или + `config.json.example`. - Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert` также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению. - Проверки кода и браузера проводить в Docker без установки ПО на хост. diff --git a/bin/ensure-focus-config.php b/bin/ensure-focus-config.php new file mode 100644 index 0000000..5e8507a --- /dev/null +++ b/bin/ensure-focus-config.php @@ -0,0 +1,68 @@ +getMessage()}\n"); + exit(2); +} + +if (!is_array($config) || !isset($config['utm']) || !is_array($config['utm'])) { + fwrite(STDERR, "В config.json отсутствует объект utm.\n"); + exit(2); +} + +if (array_key_exists('focus', $config['utm'])) { + fwrite(STDOUT, "focus уже настроен: конфиг не изменён.\n"); + exit(0); +} + +$config['utm']['focus'] = [ + 'weights' => [ + 'center' => 0, + 'face' => 1, + ], + 'default' => 'face', +]; + +try { + $encoded = json_encode( + $config, + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR + ) . "\n"; +} catch (JsonException $exception) { + fwrite(STDERR, "Не удалось сериализовать config.json: {$exception->getMessage()}\n"); + exit(2); +} + +if (file_put_contents($path, $encoded, LOCK_EX) === false) { + fwrite(STDERR, "Не удалось записать обновлённый config.json.\n"); + exit(2); +} + +fwrite(STDOUT, "Добавлен только utm.focus (face включён по умолчанию).\n"); +exit(10); diff --git a/config.json b/config.json index c31a200..b63fcbd 100644 --- a/config.json +++ b/config.json @@ -60,10 +60,10 @@ }, "focus": { "weights": { - "center": 1, - "face": 0 + "center": 0, + "face": 1 }, - "default": "center" + "default": "face" } } } diff --git a/tests/check-utm-redirect-contract.php b/tests/check-utm-redirect-contract.php index a068466..2067501 100644 --- a/tests/check-utm-redirect-contract.php +++ b/tests/check-utm-redirect-contract.php @@ -38,7 +38,7 @@ $expected = [ 'bchoice' => 'a', 'chb' => 'b', 'tp' => '1', - 'focus' => 'center', + 'focus' => 'face', 'utm_source' => 'stage-ad', 'subid' => 'click-42', ]; diff --git a/tests/check-versioned-stage-config.php b/tests/check-versioned-stage-config.php index dc04f3d..3744705 100644 --- a/tests/check-versioned-stage-config.php +++ b/tests/check-versioned-stage-config.php @@ -2,8 +2,9 @@ /** * Регрессия единственного рабочего конфига приленда. * - * Stage-конфиг — единственный канонический конфиг каждого приленда. Он должен - * быть в репозитории и попадать во все FTP/SFTP-доставки без исключений. + * Репозиторный config.json задаёт схему и стартовые значения. Рабочий конфиг + * владельца на хостинге не перезаписывается: delivery добавляет только + * отсутствующие обязательные UTM-ключи. */ declare(strict_types=1); @@ -25,7 +26,7 @@ if (!is_array($config)) { 'bchoice' => ['a' => 1, 'b' => 0, 'default' => 'a'], 'chb' => ['a' => 1, 'b' => 0, 'default' => 'b'], 'tp' => ['1' => 0, '2' => 1, 'default' => '1'], - 'focus' => ['center' => 1, 'face' => 0, 'default' => 'center'], + 'focus' => ['center' => 0, 'face' => 1, 'default' => 'face'], ]; if (($config['host'] ?? null) !== $expected['host']) { @@ -58,8 +59,11 @@ foreach ([ 'deploy-zoomstage' => $stageBlock, 'deploy-zoompixel-production' => $productionBlock, ] as $delivery => $block) { - if ($block === '' || str_contains($block, 'config\\.json$')) { - $failures[] = "{$delivery} обязан доставлять единый версионируемый config.json."; + 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."; } } From ac4cc21361326b926ae1e53ba8148db778a789e5 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sat, 17 Oct 2026 19:25:05 +0300 Subject: [PATCH 03/19] fix(config): enable explicit face focus --- AGENTS.md | 12 ++++---- README.md | 2 +- bin/ensure-focus-config.php | 25 ++++++++++------- tests/check-host-config-patch.php | 39 ++++++++++++++++++++++++++ tests/check-versioned-stage-config.php | 4 +-- 5 files changed, 64 insertions(+), 18 deletions(-) create mode 100644 tests/check-host-config-patch.php diff --git a/AGENTS.md b/AGENTS.md index 38ff8b0..f7b25f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,11 +23,13 @@ Не обходить серверные правила доступа к загрузке и результату обработки. - Рабочий `config.json` на каждом хостинге настраивает владелец. Деплой не имеет права перезаписывать его значений. При появлении новой обязательной UTM workflow - получает текущий хостовый конфиг и добавляет только отсутствующий ключ через - `bin/ensure-focus-config.php`; имеющиеся значения, включая уже настроенный - `focus`, остаются байт-в-байт прежними. `requisites.html` также живёт только на - хостинге и не перезаписывается. Не создавать target-specific конфиги или - `config.json.example`. + получает текущий хостовый конфиг и меняет только ключи, которые прямо + согласованы в задаче. Иные значения, включая уже настроенные UTM, остаются + байт-в-байт прежними. Для #274 `bin/ensure-focus-config.php` — явное + исключение: он задаёт только новый `focus=face` (default и rnd). После каждого + такого выпуска фактический stage-config следует забрать и зафиксировать в Git. + `requisites.html` также живёт только на хостинге и не перезаписывается. Не + создавать target-specific конфиги или `config.json.example`. - Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert` также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению. - Проверки кода и браузера проводить в Docker без установки ПО на хост. diff --git a/README.md b/README.md index 5da2c52..f7a6432 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Preland всегда формирует полный resolved-набор сво | `requisites.html` | реквизиты для подвала (HTML), если в `config.json` нет `requisites`. Без обоих — **заглушка «ИП Иванов Иван Иванович»** из `template.php` | | `images/`, `fonts/`, `favicon/`, `*.css` | статика; `style.min.css` встраивается в страницу | | `.htaccess` | только сроки кэширования статики (Apache) | -| `config.json` | зафиксированный Git-снимок актуального stage-конфига; runtime-файл хоста является источником истины и обычным deploy не заменяется | +| `config.json` | снимок актуального stage-конфига и схема. Рабочий конфиг на каждом хостинге настраивает владелец; deploy не заменяет его и меняет только явно согласованные в задаче ключи. После такого изменения актуальный stage-снимок забирают обратно в Git | ### `config.json` diff --git a/bin/ensure-focus-config.php b/bin/ensure-focus-config.php index 5e8507a..bc87327 100644 --- a/bin/ensure-focus-config.php +++ b/bin/ensure-focus-config.php @@ -1,11 +1,13 @@ [ 'center' => 0, 'face' => 1, @@ -49,6 +47,13 @@ $config['utm']['focus'] = [ 'default' => 'face', ]; +if (($config['utm']['focus'] ?? null) === $focus) { + fwrite(STDOUT, "focus уже включён: конфиг не изменён.\n"); + exit(0); +} + +$config['utm']['focus'] = $focus; + try { $encoded = json_encode( $config, @@ -64,5 +69,5 @@ if (file_put_contents($path, $encoded, LOCK_EX) === false) { exit(2); } -fwrite(STDOUT, "Добавлен только utm.focus (face включён по умолчанию).\n"); +fwrite(STDOUT, "Изменён только utm.focus: face включён по умолчанию и для rnd.\n"); exit(10); diff --git a/tests/check-host-config-patch.php b/tests/check-host-config-patch.php new file mode 100644 index 0000000..f3ab7f0 --- /dev/null +++ b/tests/check-host-config-patch.php @@ -0,0 +1,39 @@ + 'custom.example', + 'requisites' => ['Не менять'], + 'utm' => [ + 'theme' => ['weights' => ['dark' => 7, 'light' => 3], 'default' => 'light'], + 'focus' => ['weights' => ['center' => 9, 'face' => 1], 'default' => 'center'], + ], +]; +$path = tempnam(sys_get_temp_dir(), 'preland-config-'); +if ($path === false) { + fwrite(STDERR, "FAIL: не создан временный config.\n"); + exit(1); +} + +try { + file_put_contents($path, json_encode($fixture, JSON_THROW_ON_ERROR)); + exec(PHP_BINARY . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($path), $output, $code); + $actual = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); +} finally { + @unlink($path); +} + +if ($code !== 10 + || ($actual['host'] ?? null) !== 'custom.example' + || ($actual['requisites'] ?? null) !== ['Не менять'] + || ($actual['utm']['theme'] ?? null) !== $fixture['utm']['theme'] + || ($actual['utm']['focus'] ?? null) !== ['weights' => ['center' => 0, 'face' => 1], 'default' => 'face']) { + fwrite(STDERR, "FAIL: host-config должен сохранить все значения, кроме явно заданного focus.\n"); + exit(1); +} + +fwrite(STDOUT, "OK: host config changes only explicit focus\n"); diff --git a/tests/check-versioned-stage-config.php b/tests/check-versioned-stage-config.php index 3744705..e0b64f5 100644 --- a/tests/check-versioned-stage-config.php +++ b/tests/check-versioned-stage-config.php @@ -4,7 +4,7 @@ * * Репозиторный config.json задаёт схему и стартовые значения. Рабочий конфиг * владельца на хостинге не перезаписывается: delivery добавляет только - * отсутствующие обязательные UTM-ключи. + * явно согласованные ключи. В #274 это единственный новый ключ focus. */ declare(strict_types=1); @@ -63,7 +63,7 @@ foreach ([ || !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."; + $failures[] = "{$delivery} обязан сохранять хостовый config.json и менять только явно согласованный focus."; } } From a86978a499a7a0fd890387e3870d9e2b32ff4136 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sat, 17 Oct 2026 20:40:46 +0300 Subject: [PATCH 04/19] chore(config): snapshot stage focus defaults --- config.json | 2 +- tests/check-utm-redirect-contract.php | 3 ++- tests/check-versioned-stage-config.php | 29 ++++++++++---------------- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/config.json b/config.json index b63fcbd..8b4049a 100644 --- a/config.json +++ b/config.json @@ -56,7 +56,7 @@ "1": 0, "2": 1 }, - "default": "1" + "default": "2" }, "focus": { "weights": { diff --git a/tests/check-utm-redirect-contract.php b/tests/check-utm-redirect-contract.php index 2067501..a136f83 100644 --- a/tests/check-utm-redirect-contract.php +++ b/tests/check-utm-redirect-contract.php @@ -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', diff --git a/tests/check-versioned-stage-config.php b/tests/check-versioned-stage-config.php index e0b64f5..20badda 100644 --- a/tests/check-versioned-stage-config.php +++ b/tests/check-versioned-stage-config.php @@ -17,27 +17,20 @@ $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}."; } } } From 730042385db38eb7b89f9ddcb7d9eca7896afb27 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sun, 18 Oct 2026 00:08:46 +0300 Subject: [PATCH 05/19] fix(ci): use Alpine php83 for host config --- .gitea/workflows/deploy.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index f787899..84d90cf 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -29,7 +29,8 @@ jobs: include: # name — имя цели, url — адрес преленда (для проверки), prefix — префикс секретов _FTP_*, # dir — каталог сайта на FTP ("" — корень FTP, как на beget). Новый домен — новая запись и три секрета; - # requisites.html создают на хостинге цели. Рабочий config.json приходит из Git. + # requisites.html создают на хостинге цели. Рабочий config.json остаётся + # на хостинге и обычным deploy не заменяется. - name: barbariskatest url: https://preland.anton.barbariskatest.ru prefix: PRELAND @@ -66,7 +67,8 @@ jobs: 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=$? + # Alpine package php83-cli предоставляет бинарник php83, не php. + php83 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 \ From a0dee79b225526015e6340ee674ea6e1d0ea4d4e Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sun, 18 Oct 2026 00:19:05 +0300 Subject: [PATCH 06/19] fix(ci): run legacy release on production runner --- .gitea/workflows/deploy.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 84d90cf..5d34901 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -21,7 +21,9 @@ jobs: deploy: name: deploy ${{ matrix.name }} if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest + # Legacy — тоже production-выпуск: выполняем на выделенном runner, а не + # на общем Alpine-пуле, чтобы не зависеть от внешних apk mirrors. + runs-on: zoomzilla-production strategy: fail-fast: false @@ -45,8 +47,8 @@ jobs: REMOTE_DIR: ${{ matrix.dir }} steps: - - name: Install tools (Alpine) - run: apk add --no-cache git curl php83-cli + - name: Install tools + run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl php-cli - name: Checkout repository uses: actions/checkout@v4 @@ -67,8 +69,7 @@ jobs: curl -sS --ssl-reqd --netrc-file "$netrc" --fail --retry 3 \ "ftp://$FTP_HOST/${dir:+$dir/}config.json" -o "$config" changed=0 - # Alpine package php83-cli предоставляет бинарник php83, не php. - php83 bin/ensure-focus-config.php "$config" || changed=$? + 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 \ From 35b6a334ba5617d84bd7f916a32327833ad6c9b2 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sun, 18 Oct 2026 00:28:31 +0300 Subject: [PATCH 07/19] fix(ci): clone legacy release without node checkout --- .gitea/workflows/deploy.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 5d34901..e3c7aa4 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -50,8 +50,11 @@ jobs: - name: Install tools run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl php-cli - - name: Checkout repository - uses: actions/checkout@v4 + # На production runner образ задания не содержит Node.js, поэтому + # actions/checkout не запускается. Клонируем тот же main напрямую: + # это не меняет содержимое host config.json. + - 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: From d646f98aa766576a06349d3ebbb6f4374558360b Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sun, 18 Oct 2026 00:34:31 +0300 Subject: [PATCH 08/19] fix(ci): upload legacy files independently --- .gitea/workflows/deploy.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e3c7aa4..32dd60b 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -91,15 +91,20 @@ jobs: 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 + netrc=$(mktemp) + trap 'rm -f "$netrc"' 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" + # Legacy FTP разрывает многокомандную сессию кодом 426. Загружаем + # файлы отдельными FTPS-командами: при ошибке в журнале останется + # конкретный путь, а повторная отправка безопасна. + while read -r f; do + echo "загрузка: $f" + curl -sS --ssl-reqd --netrc-file "$netrc" --ftp-create-dirs --retry 3 --fail \ + -T "$f" "ftp://$FTP_HOST/${dir:+$dir/}$f" + done < files.txt echo "залито файлов: $(wc -l < files.txt)" - name: Check the page From 97fe9646b76475625589efe6f1f09fef1d6fd31b Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sun, 18 Oct 2026 00:40:02 +0300 Subject: [PATCH 09/19] fix(ci): verify legacy FTP retry payloads --- .gitea/workflows/deploy.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 32dd60b..a679736 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -91,8 +91,8 @@ jobs: 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) - trap 'rm -f "$netrc"' EXIT + netrc=$(mktemp); verify=$(mktemp) + trap 'rm -f "$netrc" "$verify"' 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:// как есть — пробелы и прочее экранирование не поддерживаем @@ -102,8 +102,17 @@ jobs: # конкретный путь, а повторная отправка безопасна. while read -r f; do echo "загрузка: $f" - curl -sS --ssl-reqd --netrc-file "$netrc" --ftp-create-dirs --retry 3 --fail \ - -T "$f" "ftp://$FTP_HOST/${dir:+$dir/}$f" + if curl -sS --ssl-reqd --netrc-file "$netrc" --ftp-create-dirs --retry 3 --fail \ + -T "$f" "ftp://$FTP_HOST/${dir:+$dir/}$f"; then + continue + fi + # Этот FTP-сервер иногда возвращает 426 уже после успешной записи + # бинарного файла. Не считаем передачу успешной вслепую: скачиваем + # объект обратно и сверяем байты с исходным файлом. + curl -sS --ssl-reqd --netrc-file "$netrc" --fail --retry 3 \ + "ftp://$FTP_HOST/${dir:+$dir/}$f" -o "$verify" + cmp -s "$f" "$verify" || { echo "файл не совпал после FTP-ошибки: $f"; exit 1; } + echo "FTP 426 подтверждён сверкой: $f" done < files.txt echo "залито файлов: $(wc -l < files.txt)" From 498e2df7b93825e1c3c91a23c96ebb5479cfeb78 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sun, 18 Oct 2026 00:46:02 +0300 Subject: [PATCH 10/19] fix(ci): use FTPS client for legacy assets --- .gitea/workflows/deploy.yml | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index a679736..444864c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -48,7 +48,7 @@ jobs: steps: - name: Install tools - run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl php-cli + run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl lftp php-cli # На production runner образ задания не содержит Node.js, поэтому # actions/checkout не запускается. Клонируем тот же main напрямую: @@ -91,29 +91,30 @@ jobs: 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); verify=$(mktemp) - trap 'rm -f "$netrc" "$verify"' EXIT - printf 'machine %s login %s password %s\n' "$FTP_HOST" "$FTP_USER" "$FTP_PASS" > "$netrc" + script=$(mktemp) + trap 'rm -f "$script"' EXIT 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 - # Legacy FTP разрывает многокомандную сессию кодом 426. Загружаем - # файлы отдельными FTPS-командами: при ошибке в журнале останется - # конкретный путь, а повторная отправка безопасна. + { + echo 'set cmd:fail-exit yes' + echo 'set ftp:ssl-force true' + echo 'set ftp:ssl-protect-data true' + echo 'set ssl:verify-certificate yes' + echo 'set net:max-retries 3' + echo 'set net:timeout 30' + } > "$script" + # Сервер legacy обрывает бинарные загрузки curl с 426. lftp ведёт + # каждую передачу отдельной FTPS-командой, не ослабляя TLS. while read -r f; do echo "загрузка: $f" - if curl -sS --ssl-reqd --netrc-file "$netrc" --ftp-create-dirs --retry 3 --fail \ - -T "$f" "ftp://$FTP_HOST/${dir:+$dir/}$f"; then - continue - fi - # Этот FTP-сервер иногда возвращает 426 уже после успешной записи - # бинарного файла. Не считаем передачу успешной вслепую: скачиваем - # объект обратно и сверяем байты с исходным файлом. - curl -sS --ssl-reqd --netrc-file "$netrc" --fail --retry 3 \ - "ftp://$FTP_HOST/${dir:+$dir/}$f" -o "$verify" - cmp -s "$f" "$verify" || { echo "файл не совпал после FTP-ошибки: $f"; exit 1; } - echo "FTP 426 подтверждён сверкой: $f" + remote_dir=${f%/*} + [ "$remote_dir" = "$f" ] && remote_dir='.' + printf 'mkdir -p %s%s\nput -O %s%s %s\n' \ + "${dir:+$dir/}" "$remote_dir" "${dir:+$dir/}" "$remote_dir" "$f" >> "$script" done < files.txt + printf 'bye\n' >> "$script" + lftp -u "$FTP_USER","$FTP_PASS" "ftp://$FTP_HOST" -f "$script" echo "залито файлов: $(wc -l < files.txt)" - name: Check the page From be030f25a0e964d1ee91079fbf994219cd134476 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sun, 18 Oct 2026 00:50:00 +0300 Subject: [PATCH 11/19] fix(ci): source legacy FTPS command file --- .gitea/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 444864c..648a550 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -114,7 +114,7 @@ jobs: "${dir:+$dir/}" "$remote_dir" "${dir:+$dir/}" "$remote_dir" "$f" >> "$script" done < files.txt printf 'bye\n' >> "$script" - lftp -u "$FTP_USER","$FTP_PASS" "ftp://$FTP_HOST" -f "$script" + lftp -u "$FTP_USER","$FTP_PASS" -e "source $script" "ftp://$FTP_HOST" echo "залито файлов: $(wc -l < files.txt)" - name: Check the page From 78b24c91f85c7abb3cd8791a24e9e7b8b3caa015 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sun, 18 Oct 2026 00:53:41 +0300 Subject: [PATCH 12/19] fix(ci): skip root directory creation on legacy FTP --- .gitea/workflows/deploy.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 648a550..f3d76d5 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -110,8 +110,11 @@ jobs: echo "загрузка: $f" remote_dir=${f%/*} [ "$remote_dir" = "$f" ] && remote_dir='.' - printf 'mkdir -p %s%s\nput -O %s%s %s\n' \ - "${dir:+$dir/}" "$remote_dir" "${dir:+$dir/}" "$remote_dir" "$f" >> "$script" + if [ "$remote_dir" != '.' ]; then + printf 'mkdir -p %s%s\n' "${dir:+$dir/}" "$remote_dir" >> "$script" + fi + printf 'put -O %s%s %s\n' \ + "${dir:+$dir/}" "$remote_dir" "$f" >> "$script" done < files.txt printf 'bye\n' >> "$script" lftp -u "$FTP_USER","$FTP_PASS" -e "source $script" "ftp://$FTP_HOST" From 56456bb0cb14b3f54ca3ea0103888f5f5794a01f Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sun, 18 Oct 2026 00:57:20 +0300 Subject: [PATCH 13/19] fix(ci): tolerate existing legacy FTP directories --- .gitea/workflows/deploy.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index f3d76d5..5303581 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -111,7 +111,10 @@ jobs: remote_dir=${f%/*} [ "$remote_dir" = "$f" ] && remote_dir='.' if [ "$remote_dir" != '.' ]; then - printf 'mkdir -p %s%s\n' "${dir:+$dir/}" "$remote_dir" >> "$script" + # Legacy-сервер возвращает 550 и для уже существующего каталога. + # Ошибку mkdir гасим, но put по-прежнему завершит выпуск ошибкой, + # если каталог действительно нельзя использовать. + printf 'mkdir -p %s%s || echo\n' "${dir:+$dir/}" "$remote_dir" >> "$script" fi printf 'put -O %s%s %s\n' \ "${dir:+$dir/}" "$remote_dir" "$f" >> "$script" From 19547d1797e50b51fff75045c78843d861e6a37e Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sun, 18 Oct 2026 01:04:06 +0300 Subject: [PATCH 14/19] fix(ci): deploy only changed legacy application files --- .gitea/workflows/deploy.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 5303581..02a103e 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -45,6 +45,10 @@ jobs: env: PRELAND_URL: ${{ matrix.url }} REMOTE_DIR: ${{ matrix.dir }} + # Последний успешно опубликованный legacy-снимок. На нестабильный FTP + # отправляем только изменения приложения после него, а не весь набор + # уже присутствующих медиафайлов. + LEGACY_DEPLOY_BASE: dfbb086e2b131a084f3de5b0f92c9cd69616c01a steps: - name: Install tools @@ -54,7 +58,7 @@ jobs: # actions/checkout не запускается. Клонируем тот же main напрямую: # это не меняет содержимое host config.json. - name: Checkout production branch - run: git clone --depth 1 --branch main http://git.dira89kv.beget.tech:3000/anton/preland.git . + run: git clone --branch main http://git.dira89kv.beget.tech:3000/anton/preland.git . - name: Preserve host config and add focus if absent env: @@ -93,7 +97,9 @@ jobs: umask 077 script=$(mktemp) trap 'rm -f "$script"' EXIT - git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$|config\.json$)' > files.txt + git diff --name-only "$LEGACY_DEPLOY_BASE"..HEAD > changed-files.txt + grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$|config\.json$|bin/|tests/)' \ + changed-files.txt > files.txt || true # имя уходит в адрес ftp:// как есть — пробелы и прочее экранирование не поддерживаем if grep -q '[^A-Za-z0-9._/-]' files.txt; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' files.txt; exit 1; fi { From 21f8fbb6a46a5bc72d304b355c5142e2576078e1 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Mon, 19 Oct 2026 12:19:33 +0300 Subject: [PATCH 15/19] =?UTF-8?q?feat(upload):=20=D1=84=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=20=D0=B8=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=BE=D1=82=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=BC=D0=B8=20=D0=BF=D1=83=D1=82?= =?UTF-8?q?=D1=8F=D0=BC=D0=B8=20=D0=BD=D0=B0=20Android?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++ components/browser-upload.php | 82 +++++++++++++++++++++++++++++ components/form.php | 20 +++++-- components/upload-picker-links.php | 22 ++++++++ messages/en/app.php | 4 ++ tests/check-visible-format-list.php | 2 +- 6 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 components/browser-upload.php create mode 100644 components/upload-picker-links.php diff --git a/README.md b/README.md index f7a6432..7063eef 100644 --- a/README.md +++ b/README.md @@ -160,3 +160,7 @@ Preland всегда формирует полный resolved-набор сво **Откат вручную.** Перед заменой файлов — копия изменяемых файлов (`index.php`, `components/*`) с хостинга; вернуть их той же загрузкой. Новый преленд требует на сайте `/api/images/public/claim` (api с 2026-10-05): со старым api переход по ключу отдаст 404. + +## Android: фото и файлы (2026-10-19) +Основная кнопка выбирает image/*; отдельная ссылка «Выбрать файлы (PDF и др.)» открывает общий picker без ограничения image/*. PDF в подписи скрывается по runtime API-policy. iOS/iPad/Desktop сохраняют прежний общий выбор. +много сохраняется в одной компактной mobile-строке. components/browser-upload.php и CSS upload-picker-links.php — лёгкие inline-копии соответствующих компонентов API: отдельный PHP-хост не использует Yii AssetManager, новый внешний запрос не добавляется. При изменении общего контракта синхронизировать обе копии; API mobile-picker.cjs покрывает обе реальные страницы. Android byte-copy FileProvider сохраняет исходные байты/имя/MIME и ограничен max_file_size_bytes; до ответа policy используется консервативный15MB предел копирования. UTM/client_key/claim/config.json не меняются. + diff --git a/components/browser-upload.php b/components/browser-upload.php new file mode 100644 index 0000000..fd06a54 --- /dev/null +++ b/components/browser-upload.php @@ -0,0 +1,82 @@ + + diff --git a/components/form.php b/components/form.php index a832a3c..e056f00 100644 --- a/components/form.php +++ b/components/form.php @@ -1,4 +1,5 @@ -
+
@@ -26,7 +27,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
HEIC
WebP
GIF
-
PDF
+
PDF
+много
@@ -37,6 +38,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a + @@ -78,14 +80,16 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a // отправляется в content-based Tour gate, где и показывается controlled error. let pickerAccept = ; 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 = ; if (Number.isSafeInteger(policy?.max_file_size_bytes) && policy.max_file_size_bytes > 0) { maxUploadBytes = policy.max_file_size_bytes; } @@ -124,7 +128,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a uploadPending = false; if (uploadDotsTimer !== null) window.clearInterval(uploadDotsTimer); uploadDotsTimer = null; - if (uploadButtonLabel) uploadButtonLabel.textContent = uploadButtonDefaultLabel; + if (uploadButtonLabel) uploadButtonLabel.textContent = uploadButtonLabel.dataset.pickerLabel || uploadButtonDefaultLabel; if (uploadButton) { uploadButton.disabled = false; uploadButton.removeAttribute('aria-busy'); @@ -217,12 +221,18 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a } function uploadFile(file) { + // Тот же обход нестабильного Android FileProvider, что на основном сайте. + window.ZoomzillaUpload.prepare(file, maxUploadBytes || 15 * 1024 * 1024) + .then(bytes => uploadPreparedFile(file, bytes)) + .catch(() => showUploadError(genericErrorMsg)); + } + function uploadPreparedFile(file, bytes) { const xhr = new XMLHttpRequest(); const formData = new FormData(); // Ключ загрузки: по нему сайт передаёт её в сессию посетителя (/api/images/public/claim, годен 30 мин). // randomUUID есть в HTTPS-контексте; где его нет (старый Safari), есть getRandomValues. const clientKey = createClientKey(); - formData.append('file', file); + formData.append('file', bytes, file.name || 'upload'); formData.append('client_key', clientKey); const actionUrl = createApiUrl || formEl.getAttribute('action') || '/api/images/public/create'; diff --git a/components/upload-picker-links.php b/components/upload-picker-links.php new file mode 100644 index 0000000..2fb6796 --- /dev/null +++ b/components/upload-picker-links.php @@ -0,0 +1,22 @@ + + + + + diff --git a/messages/en/app.php b/messages/en/app.php index d3b7148..a608f11 100644 --- a/messages/en/app.php +++ b/messages/en/app.php @@ -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', diff --git a/tests/check-visible-format-list.php b/tests/check-visible-format-list.php index 1eab42c..057812f 100644 --- a/tests/check-visible-format-list.php +++ b/tests/check-visible-format-list.php @@ -11,7 +11,7 @@ if ($form === false) { exit(1); } -preg_match_all('/
([^<]+)<\\/div>/', $form, $formatMatches); +preg_match_all('/
]*>([^<]+)<\\/div>/', $form, $formatMatches); $visibleFormats = ['JPG', 'PNG', 'HEIC', 'WebP', 'GIF', 'PDF', '+много']; if ($formatMatches[1] !== $visibleFormats) { fwrite(STDERR, "FAIL: Preland не показывает утверждённый компактный список форматов в нужном порядке.\n"); From 953c7dd8f26dd9609b18419fc60f3364146a13e1 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Mon, 19 Oct 2026 12:26:21 +0300 Subject: [PATCH 16/19] =?UTF-8?q?fix(upload):=20=D0=BA=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D0=B1=D0=B5=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=D0=BF=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/upload-picker-links.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/upload-picker-links.php b/components/upload-picker-links.php index 2fb6796..9eb19f3 100644 --- a/components/upload-picker-links.php +++ b/components/upload-picker-links.php @@ -9,7 +9,7 @@ #dropzone-form[data-mobile-upload] .dropzone-form-format-list { flex-wrap: nowrap; gap: 4px; justify-content: center; } #dropzone-form[data-mobile-upload] .dropzone-form-format-item { padding: 3px 5px; border-radius: 6px; font-size: 11px; line-height: 15px; white-space: nowrap; } #dropzone-form[data-mobile-upload] .dropzone-form-action { margin-top: 12px; display: flex; flex-direction: column; align-items: center; gap: 6px; } -#dropzone-form[data-mobile-upload] .dropzone-form-button { width: 100%; } +#dropzone-form[data-mobile-upload] .dropzone-form-button { width: 100%; pointer-events: auto; } #dropzone-form[data-mobile-upload] [data-picker-or] { display: block; margin: 0; font-size: 10px; line-height: 14px; color: #979aa0; } #dropzone-form[data-mobile-upload] [data-files-picker] { display: block; position: relative; margin: -2px 0 0; font-size: 12px; line-height: 18px; text-decoration: underline; text-underline-offset: 3px; pointer-events: auto; } #dropzone-form.theme-dark [data-files-picker], .theme-dark #dropzone-form [data-files-picker] { color: #b6caff; } From 3319499d5edd019664dbf2ecd4ceaf6387f7c7e9 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Mon, 19 Oct 2026 13:48:44 +0300 Subject: [PATCH 17/19] =?UTF-8?q?fix:=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=D1=8B=20Index=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D0=B8=20=D1=82=D1=83=D0=BB=D0=B1=D0=B0=D1=80?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/preview.php | 2 +- style.css | 13 +++++++------ style.min.c.css | 12 ++++++------ style.min.css | 2 +- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/components/preview.php b/components/preview.php index 087c8f6..1dbdf98 100644 --- a/components/preview.php +++ b/components/preview.php @@ -41,7 +41,7 @@ } @media (max-width: 768px) { .image-loading-progress { - height: 35dvh !important; + height: 35svh !important; } .image-loading-progress #source { position: static; diff --git a/style.css b/style.css index 97ac215..5e64e81 100644 --- a/style.css +++ b/style.css @@ -2745,29 +2745,30 @@ 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; } } \ No newline at end of file diff --git a/style.min.c.css b/style.min.c.css index e83c52b..25a5ffb 100644 --- a/style.min.c.css +++ b/style.min.c.css @@ -756,24 +756,24 @@ 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; } } \ No newline at end of file diff --git a/style.min.css b/style.min.css index ed3c400..461b22e 100644 --- a/style.min.css +++ b/style.min.css @@ -1 +1 @@ -.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}.dropzone-form-loading-info-icon,.header-navbar .nav-link svg,.image-examples-label{flex-shrink:0}.header-navbar .nav-link:hover{background-color:rgba(255,255,255,.6)!important;color:#871ea3}.gradient-dashed-border{position:relative;border-radius:24px}.gradient-dashed-border::before{content:"";position:absolute;z-index:10;inset:0;border-radius:inherit;pointer-events:none;background:linear-gradient(135deg,#168cff 0,#705cff 50%,#ed00c8 100%);-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3Crect x='0.5' y='0.5' width='99' height='99' rx='6' ry='6' fill='none' stroke='white' stroke-width='1.5' stroke-dasharray='7 8' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E") center/100% 100% no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3Crect x='0.5' y='0.5' width='99' height='99' rx='6' ry='6' fill='none' stroke='white' stroke-width='1.5' stroke-dasharray='7 8' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E") center/100% 100% no-repeat}.dropzone-form{border-radius:3px;background:0 0;border:none;padding:20px}.dropzone-form-inner{text-align:center;padding-top:76px;padding-bottom:76px}.dropzone-form-icon{display:flex;justify-content:center}.dropzone-form-icon img{max-width:100%;height:auto}.dropzone-form-text{font-size:18px;line-height:1.5;margin-top:18px}.dropzone-form-format{margin-top:24px;display:flex;color:#979aa0;font-size:12px;gap:12px;flex-wrap:wrap;align-items:center;justify-content:center}.dropzone-form-format-label{color:#2b2d2f}.dropzone-form-format-list{display:flex;gap:8px;flex-wrap:wrap}.dropzone-form-format-item{display:inline-flex;align-items:center;padding:4px 8px;border:1px solid #e5e5e7;border-radius:8px;background-color:#f8f9fb;color:#596574}.dropzone-form-action{margin-top:24px}.dropzone-form-button{display:inline-flex;padding:0 24px;height:48px;align-items:center;justify-content:center;text-align:center;gap:6px;border:none;background:linear-gradient(to bottom,#3d87e3,#990694);color:#fff;font-weight:600;border-radius:16px;cursor:pointer}.dropzone-form-button svg{width:20px;height:20px}.dropzone-form-loading .dropzone-form-inner{padding:86px 16px}.dropzone-form-filename{font-size:16px;color:#979aa0;margin-top:10px}.dropzone-form-loading-info{margin-top:24px;display:flex;align-items:center;justify-content:space-between}.dropzone-form-loading-info-item{display:flex;gap:8px}.dropzone-form-loading-info-item:not(:last-child),.image-loading-error-icon{margin-bottom:8px}.dropzone-form-loading-info-icon svg{width:24px;height:24px}.dropzone-form-loading-info-text{font-size:14px;line-height:1.5;text-align:left}.dropzone-form-loading-info-arrow{flex-basis:54px}.dropzone-form-loading-info-arrow svg{width:37px;height:24px}.dropzone-form-loading-progress{margin-top:24px;max-width:368px;margin-left:auto;margin-right:auto}.dropzone-form-loading-progress .progress{height:29px;border-radius:100px;background-color:transparent;border:1px solid #fff;position:relative}.dropzone-form-loading-progress .progress-bar{border-radius:100px;background:linear-gradient(to bottom,#3d87e3,#990694);font-size:16px}.dropzone-form-loading-progress .progress-label{position:absolute;color:currentColor}.dropzone-form-note{margin-top:24px;font-size:16px;color:#979aa0;font-weight:500}.image-examples{margin-top:16px;display:flex;gap:16px;align-items:center}.image-examples-list{display:flex;gap:2px;flex-grow:1}.image-examples-item{flex-basis:calc((100% - 8px)/ 5);cursor:pointer}.image-examples-image{position:relative;padding-bottom:100%}.image-examples-image img{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;display:block;border-radius:8px}.image-loading img{max-width:100%;border-radius:24px}.image-loading-progress{position:relative}.image-loading-progress::before{content:"";display:block;width:100%;height:100%;position:absolute;top:0;left:0;background-color:rgba(0,0,0,.4);z-index:2;background-image:var(--source);filter:blur(15px) brightness(.7);background-size:cover;background-position:center;transform:scale(1.1)}.image-loading-progress img{position:relative;z-index:1}.image-loading-icon{position:absolute;z-index:3}.image-loading-icon img.sandglass{position:absolute;width:40px;height:40px;object-fit:contain}.image-loading-icon img.spinner{width:140px;height:140px;object-fit:contain}.image-loading-error{position:absolute;max-width:400px;width:100%;padding:16px;text-align:center;z-index:4}.image-loading-error-icon img{width:64px;height:64px;object-fit:contain}.image-loading-error-title{font-size:24px}.image-loading-error-text{font-size:14px;margin-top:10px;color:#979aa0}.footer{background-color:rgba(255,255,255,.75);padding-top:50px}.footer-main p,.footer-main ul{font-size:14px}.footer-main a{color:currentColor}.footer-bottom{border-top:1px solid #323539;font-size:12px;color:#979aa0}.theme-dark,.theme-dark .btn{color:#f9f9f9}.theme-dark .wrapper{background-image:url(../images/dark-bg.jpg)}.theme-dark .btn-header{border-color:#fff;color:#fff}.theme-dark .btn-header:hover{color:#fff}.theme-dark .footer-logo img:nth-child(2),.theme-dark .navbar-brand img:nth-child(2){display:block}.theme-dark .header{background-color:transparent}.theme-dark .header .navbar{border-color:#323539}.theme-dark .header .navbar-collapse{background-color:rgba(1,1,3,.9)}.theme-dark .header .header-navbar .nav-link:hover{background-color:#0b0b10!important}.theme-dark .dropzone-form-format-label{color:#979aa0}.theme-dark .dropzone-form-format-item{border-color:#323539;background-color:#0b0b10;color:#979aa0}.theme-dark .footer{background-color:rgba(2,2,3,.5)}@media (max-width:767.98px){.nav-link-auth{display:inline-flex!important}.site{padding-top:60px}.navbar-toggler{width:40px;height:40px;padding:0;border-radius:8px}.image-examples{display:block;text-align:center}.image-examples-label{margin-bottom:16px}.image-loading-error-title{font-size:18px}.image-loading-error-text{margin-top:8px;font-size:13px}.dropzone-form-inner,.dropzone-form-loading .dropzone-form-inner{padding-top:0;padding-bottom:0}.dropzone-form-text{font-size:16px;margin-top:8px}.dropzone-form-format{margin-top:8px;row-gap:8px}.dropzone-form-action{margin-top:8px}#dropzone-form,#preview{margin-top:16px}.footer{padding-top:0}.navbar-brand img{max-width:130px}.dropzone-form-loading-info-text,.footer-main p,.footer-main ul{font-size:13px}.footer-main .h5{font-size:16px}.footer-bottom{font-size:10px}.dropzone-form-button{font-size:14px;padding-left:14px;padding-right:14px;width:100%}.dropzone-form-loading-info{margin:0 5%}.main-section{justify-content:unset}.dropzone-form-filename,.dropzone-form-note{font-size:14px}.gradient-dashed-border{border-radius:10px}}@media (orientation:portrait){.hide-on-pc{display:block}.hide-on-mob{display:none}}@media (orientation:landscape){.hide-on-pc{display:none!important}.hide-on-mob{display:block}}@media (orientation:portrait),(max-aspect-ratio:1/1){.image-examples{max-width:calc(100dvh / 3);margin:0 auto}#dropzone-form{padding:2dvh 2.75dvh}.dropzone-form-icon{max-height:8dvh}.dropzone-form-text,.image-examples-label{font-size:2.45dvh}.dropzone-form-format-item,.dropzone-form-format-label{font-size:1.8dvh}.main-section{padding-top:2dvh!important}}.native-icv .icv__handle-circle::after,.native-icv .icv__handle-circle::before{content:'';display:inline-block;width:14px;height:14px;background-repeat:no-repeat;background-position:center;background-size:contain}.image-compare.native-icv{position:relative;overflow:hidden;user-select:none;-webkit-user-select:none;touch-action:pan-y;cursor:ew-resize;aspect-ratio:1696/920;border-radius:24px}.native-icv .icv__before-wrap{position:absolute;top:0;left:0;height:100%;width:50%;overflow:hidden;z-index:2}.native-icv .icv__after-img,.native-icv .icv__before-wrap img{left:0;width:100%;top:0;position:absolute;pointer-events:none;height:100%;object-fit:cover}.native-icv .icv__after-img{display:block}.native-icv .icv__handle{position:absolute;top:0;bottom:0;left:50%;width:2px;background:#fff;z-index:3;transform:translateX(-50%);box-shadow:0 0 10px rgba(0,0,0,.5)}.native-icv .icv__handle-circle{position:absolute;top:50%;left:50%;width:38px;height:38px;background:#fff;border-radius:50%;transform:translate(-50%,-50%);box-shadow:0 2px 8px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center}.native-icv .icv__handle-circle::before{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='15 18 9 12 15 6'%3E%3C/polyline%3E%3C/svg%3E")}.native-icv .icv__handle-circle::after{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'%3E%3C/polyline%3E%3C/svg%3E")}.native-icv .icv__label{position:absolute;top:1rem;z-index:4;font-size:12px;background-color:rgba(255,255,255,.45);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);color:#333;border:1px solid rgba(255,255,255,.7);border-radius:8px;padding:4px 10px;pointer-events:none;font-weight:500}.native-icv .icv__label-before{left:1rem}.native-icv .icv__label-after{right:1rem}#image-compare:not(.native-icv):not(.icv){position:relative;display:flex;overflow:hidden;aspect-ratio:1696/920;border-radius:24px}#image-compare:not(.native-icv):not(.icv) img:first-of-type{width:50%;height:100%;object-fit:cover;object-position:left}#image-compare:not(.native-icv):not(.icv) img:last-of-type{width:50%;height:100%;object-fit:cover;object-position:right}.image-examples-item.active{border-color:#007bff}.deferred-thumb{transition:opacity .25s ease-in}.deferred-thumb:not([src]){opacity:0} \ No newline at end of file +.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}.dropzone-form-loading-info-icon,.header-navbar .nav-link svg,.image-examples-label{flex-shrink:0}.header-navbar .nav-link:hover{background-color:rgba(255,255,255,.6)!important;color:#871ea3}.gradient-dashed-border{position:relative;border-radius:24px}.gradient-dashed-border::before{content:"";position:absolute;z-index:10;inset:0;border-radius:inherit;pointer-events:none;background:linear-gradient(135deg,#168cff 0,#705cff 50%,#ed00c8 100%);-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3Crect x='0.5' y='0.5' width='99' height='99' rx='6' ry='6' fill='none' stroke='white' stroke-width='1.5' stroke-dasharray='7 8' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E") center/100% 100% no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3Crect x='0.5' y='0.5' width='99' height='99' rx='6' ry='6' fill='none' stroke='white' stroke-width='1.5' stroke-dasharray='7 8' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E") center/100% 100% no-repeat}.dropzone-form{border-radius:3px;background:0 0;border:none;padding:20px}.dropzone-form-inner{text-align:center;padding-top:76px;padding-bottom:76px}.dropzone-form-icon{display:flex;justify-content:center}.dropzone-form-icon img{max-width:100%;height:auto}.dropzone-form-text{font-size:18px;line-height:1.5;margin-top:18px}.dropzone-form-format{margin-top:24px;display:flex;color:#979aa0;font-size:12px;gap:12px;flex-wrap:wrap;align-items:center;justify-content:center}.dropzone-form-format-label{color:#2b2d2f}.dropzone-form-format-list{display:flex;gap:8px;flex-wrap:wrap}.dropzone-form-format-item{display:inline-flex;align-items:center;padding:4px 8px;border:1px solid #e5e5e7;border-radius:8px;background-color:#f8f9fb;color:#596574}.dropzone-form-action{margin-top:24px}.dropzone-form-button{display:inline-flex;padding:0 24px;height:48px;align-items:center;justify-content:center;text-align:center;gap:6px;border:none;background:linear-gradient(to bottom,#3d87e3,#990694);color:#fff;font-weight:600;border-radius:16px;cursor:pointer}.dropzone-form-button svg{width:20px;height:20px}.dropzone-form-loading .dropzone-form-inner{padding:86px 16px}.dropzone-form-filename{font-size:16px;color:#979aa0;margin-top:10px}.dropzone-form-loading-info{margin-top:24px;display:flex;align-items:center;justify-content:space-between}.dropzone-form-loading-info-item{display:flex;gap:8px}.dropzone-form-loading-info-item:not(:last-child),.image-loading-error-icon{margin-bottom:8px}.dropzone-form-loading-info-icon svg{width:24px;height:24px}.dropzone-form-loading-info-text{font-size:14px;line-height:1.5;text-align:left}.dropzone-form-loading-info-arrow{flex-basis:54px}.dropzone-form-loading-info-arrow svg{width:37px;height:24px}.dropzone-form-loading-progress{margin-top:24px;max-width:368px;margin-left:auto;margin-right:auto}.dropzone-form-loading-progress .progress{height:29px;border-radius:100px;background-color:transparent;border:1px solid #fff;position:relative}.dropzone-form-loading-progress .progress-bar{border-radius:100px;background:linear-gradient(to bottom,#3d87e3,#990694);font-size:16px}.dropzone-form-loading-progress .progress-label{position:absolute;color:currentColor}.dropzone-form-note{margin-top:24px;font-size:16px;color:#979aa0;font-weight:500}.image-examples{margin-top:16px;display:flex;gap:16px;align-items:center}.image-examples-list{display:flex;gap:2px;flex-grow:1}.image-examples-item{flex-basis:calc((100% - 8px)/ 5);cursor:pointer}.image-examples-image{position:relative;padding-bottom:100%}.image-examples-image img{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;display:block;border-radius:8px}.image-loading img{max-width:100%;border-radius:24px}.image-loading-progress{position:relative}.image-loading-progress::before{content:"";display:block;width:100%;height:100%;position:absolute;top:0;left:0;background-color:rgba(0,0,0,.4);z-index:2;background-image:var(--source);filter:blur(15px) brightness(.7);background-size:cover;background-position:center;transform:scale(1.1)}.image-loading-progress img{position:relative;z-index:1}.image-loading-icon{position:absolute;z-index:3}.image-loading-icon img.sandglass{position:absolute;width:40px;height:40px;object-fit:contain}.image-loading-icon img.spinner{width:140px;height:140px;object-fit:contain}.image-loading-error{position:absolute;max-width:400px;width:100%;padding:16px;text-align:center;z-index:4}.image-loading-error-icon img{width:64px;height:64px;object-fit:contain}.image-loading-error-title{font-size:24px}.image-loading-error-text{font-size:14px;margin-top:10px;color:#979aa0}.footer{background-color:rgba(255,255,255,.75);padding-top:50px}.footer-main p,.footer-main ul{font-size:14px}.footer-main a{color:currentColor}.footer-bottom{border-top:1px solid #323539;font-size:12px;color:#979aa0}.theme-dark,.theme-dark .btn{color:#f9f9f9}.theme-dark .wrapper{background-image:url(../images/dark-bg.jpg)}.theme-dark .btn-header{border-color:#fff;color:#fff}.theme-dark .btn-header:hover{color:#fff}.theme-dark .footer-logo img:nth-child(2),.theme-dark .navbar-brand img:nth-child(2){display:block}.theme-dark .header{background-color:transparent}.theme-dark .header .navbar{border-color:#323539}.theme-dark .header .navbar-collapse{background-color:rgba(1,1,3,.9)}.theme-dark .header .header-navbar .nav-link:hover{background-color:#0b0b10!important}.theme-dark .dropzone-form-format-label{color:#979aa0}.theme-dark .dropzone-form-format-item{border-color:#323539;background-color:#0b0b10;color:#979aa0}.theme-dark .footer{background-color:rgba(2,2,3,.5)}@media (max-width:767.98px){.nav-link-auth{display:inline-flex!important}.site{padding-top:60px}.navbar-toggler{width:40px;height:40px;padding:0;border-radius:8px}.image-examples{display:block;text-align:center}.image-examples-label{margin-bottom:16px}.image-loading-error-title{font-size:18px}.image-loading-error-text{margin-top:8px;font-size:13px}.dropzone-form-inner,.dropzone-form-loading .dropzone-form-inner{padding-top:0;padding-bottom:0}.dropzone-form-text{font-size:16px;margin-top:8px}.dropzone-form-format{margin-top:8px;row-gap:8px}.dropzone-form-action{margin-top:8px}#dropzone-form,#preview{margin-top:16px}.footer{padding-top:0}.navbar-brand img{max-width:130px}.dropzone-form-loading-info-text,.footer-main p,.footer-main ul{font-size:13px}.footer-main .h5{font-size:16px}.footer-bottom{font-size:10px}.dropzone-form-button{font-size:14px;padding-left:14px;padding-right:14px;width:100%}.dropzone-form-loading-info{margin:0 5%}.main-section{justify-content:unset}.dropzone-form-filename,.dropzone-form-note{font-size:14px}.gradient-dashed-border{border-radius:10px}}@media (orientation:portrait){.hide-on-pc{display:block}.hide-on-mob{display:none}}@media (orientation:landscape){.hide-on-pc{display:none!important}.hide-on-mob{display:block}}@media (orientation:portrait),(max-aspect-ratio:1/1){.image-examples{max-width:calc(100svh / 3);margin:0 auto}#dropzone-form{padding:2svh 2.75svh}.dropzone-form-icon{max-height:8svh}.dropzone-form-text,.image-examples-label{font-size:2.45svh}.dropzone-form-format-item,.dropzone-form-format-label{font-size:1.8svh}.main-section{padding-top:2svh!important}}.native-icv .icv__handle-circle::after,.native-icv .icv__handle-circle::before{content:'';display:inline-block;width:14px;height:14px;background-repeat:no-repeat;background-position:center;background-size:contain}.image-compare.native-icv{position:relative;overflow:hidden;user-select:none;-webkit-user-select:none;touch-action:pan-y;cursor:ew-resize;aspect-ratio:1696/920;border-radius:24px}.native-icv .icv__before-wrap{position:absolute;top:0;left:0;height:100%;width:50%;overflow:hidden;z-index:2}.native-icv .icv__after-img,.native-icv .icv__before-wrap img{left:0;width:100%;top:0;position:absolute;pointer-events:none;height:100%;object-fit:cover}.native-icv .icv__after-img{display:block}.native-icv .icv__handle{position:absolute;top:0;bottom:0;left:50%;width:2px;background:#fff;z-index:3;transform:translateX(-50%);box-shadow:0 0 10px rgba(0,0,0,.5)}.native-icv .icv__handle-circle{position:absolute;top:50%;left:50%;width:38px;height:38px;background:#fff;border-radius:50%;transform:translate(-50%,-50%);box-shadow:0 2px 8px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center}.native-icv .icv__handle-circle::before{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='15 18 9 12 15 6'%3E%3C/polyline%3E%3C/svg%3E")}.native-icv .icv__handle-circle::after{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'%3E%3C/polyline%3E%3C/svg%3E")}.native-icv .icv__label{position:absolute;top:1rem;z-index:4;font-size:12px;background-color:rgba(255,255,255,.45);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);color:#333;border:1px solid rgba(255,255,255,.7);border-radius:8px;padding:4px 10px;pointer-events:none;font-weight:500}.native-icv .icv__label-before{left:1rem}.native-icv .icv__label-after{right:1rem}#image-compare:not(.native-icv):not(.icv){position:relative;display:flex;overflow:hidden;aspect-ratio:1696/920;border-radius:24px}#image-compare:not(.native-icv):not(.icv) img:first-of-type{width:50%;height:100%;object-fit:cover;object-position:left}#image-compare:not(.native-icv):not(.icv) img:last-of-type{width:50%;height:100%;object-fit:cover;object-position:right}.image-examples-item.active{border-color:#007bff}.deferred-thumb{transition:opacity .25s ease-in}.deferred-thumb:not([src]){opacity:0} \ No newline at end of file From 029cd16d4666822d5954d59e45323f2c718a0960 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Mon, 19 Oct 2026 14:38:31 +0300 Subject: [PATCH 18/19] =?UTF-8?q?feat:=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BD=D1=8B=D0=B9=20Index=20=D0=B8=20=D0=BA=D1=80?= =?UTF-8?q?=D1=83=D0=BF=D0=BD=D1=8B=D0=B5=20=D0=BC=D0=B8=D0=BD=D0=B8=D0=B0?= =?UTF-8?q?=D1=82=D1=8E=D1=80=D1=8B=20(refs=20anton/api#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- style.css | 23 ++++++++++++++++++++++- style.min.c.css | 23 ++++++++++++++++++++++- style.min.css | 23 ++++++++++++++++++++++- template.php | 2 +- 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/style.css b/style.css index 5e64e81..05726d8 100644 --- a/style.css +++ b/style.css @@ -2771,4 +2771,25 @@ body:not(.theme-dark) .theme-light-hidden { .main-section { padding-top: 2svh !important; } -} \ No newline at end of file +} +/* 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; 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; } +} diff --git a/style.min.c.css b/style.min.c.css index 25a5ffb..625baa6 100644 --- a/style.min.c.css +++ b/style.min.c.css @@ -776,4 +776,25 @@ font-size: 1.8svh; .main-section { padding-top: 2svh !important; } -} \ No newline at end of file +} +/* 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; 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; } +} diff --git a/style.min.css b/style.min.css index 461b22e..555b360 100644 --- a/style.min.css +++ b/style.min.css @@ -1 +1,22 @@ -.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}.dropzone-form-loading-info-icon,.header-navbar .nav-link svg,.image-examples-label{flex-shrink:0}.header-navbar .nav-link:hover{background-color:rgba(255,255,255,.6)!important;color:#871ea3}.gradient-dashed-border{position:relative;border-radius:24px}.gradient-dashed-border::before{content:"";position:absolute;z-index:10;inset:0;border-radius:inherit;pointer-events:none;background:linear-gradient(135deg,#168cff 0,#705cff 50%,#ed00c8 100%);-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3Crect x='0.5' y='0.5' width='99' height='99' rx='6' ry='6' fill='none' stroke='white' stroke-width='1.5' stroke-dasharray='7 8' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E") center/100% 100% no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3Crect x='0.5' y='0.5' width='99' height='99' rx='6' ry='6' fill='none' stroke='white' stroke-width='1.5' stroke-dasharray='7 8' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E") center/100% 100% no-repeat}.dropzone-form{border-radius:3px;background:0 0;border:none;padding:20px}.dropzone-form-inner{text-align:center;padding-top:76px;padding-bottom:76px}.dropzone-form-icon{display:flex;justify-content:center}.dropzone-form-icon img{max-width:100%;height:auto}.dropzone-form-text{font-size:18px;line-height:1.5;margin-top:18px}.dropzone-form-format{margin-top:24px;display:flex;color:#979aa0;font-size:12px;gap:12px;flex-wrap:wrap;align-items:center;justify-content:center}.dropzone-form-format-label{color:#2b2d2f}.dropzone-form-format-list{display:flex;gap:8px;flex-wrap:wrap}.dropzone-form-format-item{display:inline-flex;align-items:center;padding:4px 8px;border:1px solid #e5e5e7;border-radius:8px;background-color:#f8f9fb;color:#596574}.dropzone-form-action{margin-top:24px}.dropzone-form-button{display:inline-flex;padding:0 24px;height:48px;align-items:center;justify-content:center;text-align:center;gap:6px;border:none;background:linear-gradient(to bottom,#3d87e3,#990694);color:#fff;font-weight:600;border-radius:16px;cursor:pointer}.dropzone-form-button svg{width:20px;height:20px}.dropzone-form-loading .dropzone-form-inner{padding:86px 16px}.dropzone-form-filename{font-size:16px;color:#979aa0;margin-top:10px}.dropzone-form-loading-info{margin-top:24px;display:flex;align-items:center;justify-content:space-between}.dropzone-form-loading-info-item{display:flex;gap:8px}.dropzone-form-loading-info-item:not(:last-child),.image-loading-error-icon{margin-bottom:8px}.dropzone-form-loading-info-icon svg{width:24px;height:24px}.dropzone-form-loading-info-text{font-size:14px;line-height:1.5;text-align:left}.dropzone-form-loading-info-arrow{flex-basis:54px}.dropzone-form-loading-info-arrow svg{width:37px;height:24px}.dropzone-form-loading-progress{margin-top:24px;max-width:368px;margin-left:auto;margin-right:auto}.dropzone-form-loading-progress .progress{height:29px;border-radius:100px;background-color:transparent;border:1px solid #fff;position:relative}.dropzone-form-loading-progress .progress-bar{border-radius:100px;background:linear-gradient(to bottom,#3d87e3,#990694);font-size:16px}.dropzone-form-loading-progress .progress-label{position:absolute;color:currentColor}.dropzone-form-note{margin-top:24px;font-size:16px;color:#979aa0;font-weight:500}.image-examples{margin-top:16px;display:flex;gap:16px;align-items:center}.image-examples-list{display:flex;gap:2px;flex-grow:1}.image-examples-item{flex-basis:calc((100% - 8px)/ 5);cursor:pointer}.image-examples-image{position:relative;padding-bottom:100%}.image-examples-image img{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;display:block;border-radius:8px}.image-loading img{max-width:100%;border-radius:24px}.image-loading-progress{position:relative}.image-loading-progress::before{content:"";display:block;width:100%;height:100%;position:absolute;top:0;left:0;background-color:rgba(0,0,0,.4);z-index:2;background-image:var(--source);filter:blur(15px) brightness(.7);background-size:cover;background-position:center;transform:scale(1.1)}.image-loading-progress img{position:relative;z-index:1}.image-loading-icon{position:absolute;z-index:3}.image-loading-icon img.sandglass{position:absolute;width:40px;height:40px;object-fit:contain}.image-loading-icon img.spinner{width:140px;height:140px;object-fit:contain}.image-loading-error{position:absolute;max-width:400px;width:100%;padding:16px;text-align:center;z-index:4}.image-loading-error-icon img{width:64px;height:64px;object-fit:contain}.image-loading-error-title{font-size:24px}.image-loading-error-text{font-size:14px;margin-top:10px;color:#979aa0}.footer{background-color:rgba(255,255,255,.75);padding-top:50px}.footer-main p,.footer-main ul{font-size:14px}.footer-main a{color:currentColor}.footer-bottom{border-top:1px solid #323539;font-size:12px;color:#979aa0}.theme-dark,.theme-dark .btn{color:#f9f9f9}.theme-dark .wrapper{background-image:url(../images/dark-bg.jpg)}.theme-dark .btn-header{border-color:#fff;color:#fff}.theme-dark .btn-header:hover{color:#fff}.theme-dark .footer-logo img:nth-child(2),.theme-dark .navbar-brand img:nth-child(2){display:block}.theme-dark .header{background-color:transparent}.theme-dark .header .navbar{border-color:#323539}.theme-dark .header .navbar-collapse{background-color:rgba(1,1,3,.9)}.theme-dark .header .header-navbar .nav-link:hover{background-color:#0b0b10!important}.theme-dark .dropzone-form-format-label{color:#979aa0}.theme-dark .dropzone-form-format-item{border-color:#323539;background-color:#0b0b10;color:#979aa0}.theme-dark .footer{background-color:rgba(2,2,3,.5)}@media (max-width:767.98px){.nav-link-auth{display:inline-flex!important}.site{padding-top:60px}.navbar-toggler{width:40px;height:40px;padding:0;border-radius:8px}.image-examples{display:block;text-align:center}.image-examples-label{margin-bottom:16px}.image-loading-error-title{font-size:18px}.image-loading-error-text{margin-top:8px;font-size:13px}.dropzone-form-inner,.dropzone-form-loading .dropzone-form-inner{padding-top:0;padding-bottom:0}.dropzone-form-text{font-size:16px;margin-top:8px}.dropzone-form-format{margin-top:8px;row-gap:8px}.dropzone-form-action{margin-top:8px}#dropzone-form,#preview{margin-top:16px}.footer{padding-top:0}.navbar-brand img{max-width:130px}.dropzone-form-loading-info-text,.footer-main p,.footer-main ul{font-size:13px}.footer-main .h5{font-size:16px}.footer-bottom{font-size:10px}.dropzone-form-button{font-size:14px;padding-left:14px;padding-right:14px;width:100%}.dropzone-form-loading-info{margin:0 5%}.main-section{justify-content:unset}.dropzone-form-filename,.dropzone-form-note{font-size:14px}.gradient-dashed-border{border-radius:10px}}@media (orientation:portrait){.hide-on-pc{display:block}.hide-on-mob{display:none}}@media (orientation:landscape){.hide-on-pc{display:none!important}.hide-on-mob{display:block}}@media (orientation:portrait),(max-aspect-ratio:1/1){.image-examples{max-width:calc(100svh / 3);margin:0 auto}#dropzone-form{padding:2svh 2.75svh}.dropzone-form-icon{max-height:8svh}.dropzone-form-text,.image-examples-label{font-size:2.45svh}.dropzone-form-format-item,.dropzone-form-format-label{font-size:1.8svh}.main-section{padding-top:2svh!important}}.native-icv .icv__handle-circle::after,.native-icv .icv__handle-circle::before{content:'';display:inline-block;width:14px;height:14px;background-repeat:no-repeat;background-position:center;background-size:contain}.image-compare.native-icv{position:relative;overflow:hidden;user-select:none;-webkit-user-select:none;touch-action:pan-y;cursor:ew-resize;aspect-ratio:1696/920;border-radius:24px}.native-icv .icv__before-wrap{position:absolute;top:0;left:0;height:100%;width:50%;overflow:hidden;z-index:2}.native-icv .icv__after-img,.native-icv .icv__before-wrap img{left:0;width:100%;top:0;position:absolute;pointer-events:none;height:100%;object-fit:cover}.native-icv .icv__after-img{display:block}.native-icv .icv__handle{position:absolute;top:0;bottom:0;left:50%;width:2px;background:#fff;z-index:3;transform:translateX(-50%);box-shadow:0 0 10px rgba(0,0,0,.5)}.native-icv .icv__handle-circle{position:absolute;top:50%;left:50%;width:38px;height:38px;background:#fff;border-radius:50%;transform:translate(-50%,-50%);box-shadow:0 2px 8px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center}.native-icv .icv__handle-circle::before{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='15 18 9 12 15 6'%3E%3C/polyline%3E%3C/svg%3E")}.native-icv .icv__handle-circle::after{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'%3E%3C/polyline%3E%3C/svg%3E")}.native-icv .icv__label{position:absolute;top:1rem;z-index:4;font-size:12px;background-color:rgba(255,255,255,.45);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);color:#333;border:1px solid rgba(255,255,255,.7);border-radius:8px;padding:4px 10px;pointer-events:none;font-weight:500}.native-icv .icv__label-before{left:1rem}.native-icv .icv__label-after{right:1rem}#image-compare:not(.native-icv):not(.icv){position:relative;display:flex;overflow:hidden;aspect-ratio:1696/920;border-radius:24px}#image-compare:not(.native-icv):not(.icv) img:first-of-type{width:50%;height:100%;object-fit:cover;object-position:left}#image-compare:not(.native-icv):not(.icv) img:last-of-type{width:50%;height:100%;object-fit:cover;object-position:right}.image-examples-item.active{border-color:#007bff}.deferred-thumb{transition:opacity .25s ease-in}.deferred-thumb:not([src]){opacity:0} \ No newline at end of file +.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}.dropzone-form-loading-info-icon,.header-navbar .nav-link svg,.image-examples-label{flex-shrink:0}.header-navbar .nav-link:hover{background-color:rgba(255,255,255,.6)!important;color:#871ea3}.gradient-dashed-border{position:relative;border-radius:24px}.gradient-dashed-border::before{content:"";position:absolute;z-index:10;inset:0;border-radius:inherit;pointer-events:none;background:linear-gradient(135deg,#168cff 0,#705cff 50%,#ed00c8 100%);-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3Crect x='0.5' y='0.5' width='99' height='99' rx='6' ry='6' fill='none' stroke='white' stroke-width='1.5' stroke-dasharray='7 8' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E") center/100% 100% no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3Crect x='0.5' y='0.5' width='99' height='99' rx='6' ry='6' fill='none' stroke='white' stroke-width='1.5' stroke-dasharray='7 8' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E") center/100% 100% no-repeat}.dropzone-form{border-radius:3px;background:0 0;border:none;padding:20px}.dropzone-form-inner{text-align:center;padding-top:76px;padding-bottom:76px}.dropzone-form-icon{display:flex;justify-content:center}.dropzone-form-icon img{max-width:100%;height:auto}.dropzone-form-text{font-size:18px;line-height:1.5;margin-top:18px}.dropzone-form-format{margin-top:24px;display:flex;color:#979aa0;font-size:12px;gap:12px;flex-wrap:wrap;align-items:center;justify-content:center}.dropzone-form-format-label{color:#2b2d2f}.dropzone-form-format-list{display:flex;gap:8px;flex-wrap:wrap}.dropzone-form-format-item{display:inline-flex;align-items:center;padding:4px 8px;border:1px solid #e5e5e7;border-radius:8px;background-color:#f8f9fb;color:#596574}.dropzone-form-action{margin-top:24px}.dropzone-form-button{display:inline-flex;padding:0 24px;height:48px;align-items:center;justify-content:center;text-align:center;gap:6px;border:none;background:linear-gradient(to bottom,#3d87e3,#990694);color:#fff;font-weight:600;border-radius:16px;cursor:pointer}.dropzone-form-button svg{width:20px;height:20px}.dropzone-form-loading .dropzone-form-inner{padding:86px 16px}.dropzone-form-filename{font-size:16px;color:#979aa0;margin-top:10px}.dropzone-form-loading-info{margin-top:24px;display:flex;align-items:center;justify-content:space-between}.dropzone-form-loading-info-item{display:flex;gap:8px}.dropzone-form-loading-info-item:not(:last-child),.image-loading-error-icon{margin-bottom:8px}.dropzone-form-loading-info-icon svg{width:24px;height:24px}.dropzone-form-loading-info-text{font-size:14px;line-height:1.5;text-align:left}.dropzone-form-loading-info-arrow{flex-basis:54px}.dropzone-form-loading-info-arrow svg{width:37px;height:24px}.dropzone-form-loading-progress{margin-top:24px;max-width:368px;margin-left:auto;margin-right:auto}.dropzone-form-loading-progress .progress{height:29px;border-radius:100px;background-color:transparent;border:1px solid #fff;position:relative}.dropzone-form-loading-progress .progress-bar{border-radius:100px;background:linear-gradient(to bottom,#3d87e3,#990694);font-size:16px}.dropzone-form-loading-progress .progress-label{position:absolute;color:currentColor}.dropzone-form-note{margin-top:24px;font-size:16px;color:#979aa0;font-weight:500}.image-examples{margin-top:16px;display:flex;gap:16px;align-items:center}.image-examples-list{display:flex;gap:2px;flex-grow:1}.image-examples-item{flex-basis:calc((100% - 8px)/ 5);cursor:pointer}.image-examples-image{position:relative;padding-bottom:100%}.image-examples-image img{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;display:block;border-radius:8px}.image-loading img{max-width:100%;border-radius:24px}.image-loading-progress{position:relative}.image-loading-progress::before{content:"";display:block;width:100%;height:100%;position:absolute;top:0;left:0;background-color:rgba(0,0,0,.4);z-index:2;background-image:var(--source);filter:blur(15px) brightness(.7);background-size:cover;background-position:center;transform:scale(1.1)}.image-loading-progress img{position:relative;z-index:1}.image-loading-icon{position:absolute;z-index:3}.image-loading-icon img.sandglass{position:absolute;width:40px;height:40px;object-fit:contain}.image-loading-icon img.spinner{width:140px;height:140px;object-fit:contain}.image-loading-error{position:absolute;max-width:400px;width:100%;padding:16px;text-align:center;z-index:4}.image-loading-error-icon img{width:64px;height:64px;object-fit:contain}.image-loading-error-title{font-size:24px}.image-loading-error-text{font-size:14px;margin-top:10px;color:#979aa0}.footer{background-color:rgba(255,255,255,.75);padding-top:50px}.footer-main p,.footer-main ul{font-size:14px}.footer-main a{color:currentColor}.footer-bottom{border-top:1px solid #323539;font-size:12px;color:#979aa0}.theme-dark,.theme-dark .btn{color:#f9f9f9}.theme-dark .wrapper{background-image:url(../images/dark-bg.jpg)}.theme-dark .btn-header{border-color:#fff;color:#fff}.theme-dark .btn-header:hover{color:#fff}.theme-dark .footer-logo img:nth-child(2),.theme-dark .navbar-brand img:nth-child(2){display:block}.theme-dark .header{background-color:transparent}.theme-dark .header .navbar{border-color:#323539}.theme-dark .header .navbar-collapse{background-color:rgba(1,1,3,.9)}.theme-dark .header .header-navbar .nav-link:hover{background-color:#0b0b10!important}.theme-dark .dropzone-form-format-label{color:#979aa0}.theme-dark .dropzone-form-format-item{border-color:#323539;background-color:#0b0b10;color:#979aa0}.theme-dark .footer{background-color:rgba(2,2,3,.5)}@media (max-width:767.98px){.nav-link-auth{display:inline-flex!important}.site{padding-top:60px}.navbar-toggler{width:40px;height:40px;padding:0;border-radius:8px}.image-examples{display:block;text-align:center}.image-examples-label{margin-bottom:16px}.image-loading-error-title{font-size:18px}.image-loading-error-text{margin-top:8px;font-size:13px}.dropzone-form-inner,.dropzone-form-loading .dropzone-form-inner{padding-top:0;padding-bottom:0}.dropzone-form-text{font-size:16px;margin-top:8px}.dropzone-form-format{margin-top:8px;row-gap:8px}.dropzone-form-action{margin-top:8px}#dropzone-form,#preview{margin-top:16px}.footer{padding-top:0}.navbar-brand img{max-width:130px}.dropzone-form-loading-info-text,.footer-main p,.footer-main ul{font-size:13px}.footer-main .h5{font-size:16px}.footer-bottom{font-size:10px}.dropzone-form-button{font-size:14px;padding-left:14px;padding-right:14px;width:100%}.dropzone-form-loading-info{margin:0 5%}.main-section{justify-content:unset}.dropzone-form-filename,.dropzone-form-note{font-size:14px}.gradient-dashed-border{border-radius:10px}}@media (orientation:portrait){.hide-on-pc{display:block}.hide-on-mob{display:none}}@media (orientation:landscape){.hide-on-pc{display:none!important}.hide-on-mob{display:block}}@media (orientation:portrait),(max-aspect-ratio:1/1){.image-examples{max-width:calc(100svh / 3);margin:0 auto}#dropzone-form{padding:2svh 2.75svh}.dropzone-form-icon{max-height:8svh}.dropzone-form-text,.image-examples-label{font-size:2.45svh}.dropzone-form-format-item,.dropzone-form-format-label{font-size:1.8svh}.main-section{padding-top:2svh!important}}.native-icv .icv__handle-circle::after,.native-icv .icv__handle-circle::before{content:'';display:inline-block;width:14px;height:14px;background-repeat:no-repeat;background-position:center;background-size:contain}.image-compare.native-icv{position:relative;overflow:hidden;user-select:none;-webkit-user-select:none;touch-action:pan-y;cursor:ew-resize;aspect-ratio:1696/920;border-radius:24px}.native-icv .icv__before-wrap{position:absolute;top:0;left:0;height:100%;width:50%;overflow:hidden;z-index:2}.native-icv .icv__after-img,.native-icv .icv__before-wrap img{left:0;width:100%;top:0;position:absolute;pointer-events:none;height:100%;object-fit:cover}.native-icv .icv__after-img{display:block}.native-icv .icv__handle{position:absolute;top:0;bottom:0;left:50%;width:2px;background:#fff;z-index:3;transform:translateX(-50%);box-shadow:0 0 10px rgba(0,0,0,.5)}.native-icv .icv__handle-circle{position:absolute;top:50%;left:50%;width:38px;height:38px;background:#fff;border-radius:50%;transform:translate(-50%,-50%);box-shadow:0 2px 8px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center}.native-icv .icv__handle-circle::before{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='15 18 9 12 15 6'%3E%3C/polyline%3E%3C/svg%3E")}.native-icv .icv__handle-circle::after{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'%3E%3C/polyline%3E%3C/svg%3E")}.native-icv .icv__label{position:absolute;top:1rem;z-index:4;font-size:12px;background-color:rgba(255,255,255,.45);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);color:#333;border:1px solid rgba(255,255,255,.7);border-radius:8px;padding:4px 10px;pointer-events:none;font-weight:500}.native-icv .icv__label-before{left:1rem}.native-icv .icv__label-after{right:1rem}#image-compare:not(.native-icv):not(.icv){position:relative;display:flex;overflow:hidden;aspect-ratio:1696/920;border-radius:24px}#image-compare:not(.native-icv):not(.icv) img:first-of-type{width:50%;height:100%;object-fit:cover;object-position:left}#image-compare:not(.native-icv):not(.icv) img:last-of-type{width:50%;height:100%;object-fit:cover;object-position:right}.image-examples-item.active{border-color:#007bff}.deferred-thumb{transition:opacity .25s ease-in}.deferred-thumb:not([src]){opacity:0} +/* 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; 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; } +} diff --git a/template.php b/template.php index c4f35f2..eada2eb 100644 --- a/template.php +++ b/template.php @@ -70,7 +70,7 @@
-
+
From 82749466104bec1ce41c14c02cb7c13d9b42c14b Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Mon, 19 Oct 2026 14:57:39 +0300 Subject: [PATCH 19/19] =?UTF-8?q?chore(ci):=20=D0=BE=D1=82=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20production=20=D0=B8=20legacy?= =?UTF-8?q?=20=D0=B0=D0=B2=D1=82=D0=BE=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9?= =?UTF-8?q?=20(refs=20#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yml | 247 ++++--------------------- AGENTS.md | 5 +- README.md | 56 ++---- tests/check-versioned-stage-config.php | 38 ++-- 4 files changed, 70 insertions(+), 276 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 02a103e..b434703 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,151 +1,13 @@ name: deploy-preland -# После коммита в main сохраняется legacy-выпуск на Beget. Новые домены ZoomPixel -# доставляются отдельными SFTP job: staging — только на stage, main — только на production. -# Цель — домен со своим хостингом (список — matrix ниже): реквизиты — секреты репозитория _FTP_HOST, -# _FTP_USER, _FTP_PASS (порт 21). Цели независимы: сбой одного хостинга остальные не останавливает. -# Заливаются файлы репозитория (git ls-files), кроме служебных (.gitea/, README.md, AGENTS.md, CLAUDE.md, .gitignore) и тех, что живут -# только на хостинге: requisites.html (реквизиты заполняют на месте — пустой файл из git их бы затёр). -# Рабочий config.json настраивается владельцем на каждом хостинге и является -# источником истины. Обычный deploy его не перезаписывает; задача может менять -# только прямо согласованные ключи после чтения текущего host-файла. -# На хостинге ничего не удаляется: удалённое из репозитория убирать вручную. - +# Решение владельца 19.10.2026, preland#4: только stage. +# Production выкаты временно отключены; Барбариска больше не используется. on: push: branches: - - main - staging jobs: - deploy: - name: deploy ${{ matrix.name }} - if: github.ref == 'refs/heads/main' - # Legacy — тоже production-выпуск: выполняем на выделенном runner, а не - # на общем Alpine-пуле, чтобы не зависеть от внешних apk mirrors. - runs-on: zoomzilla-production - - strategy: - fail-fast: false - matrix: - include: - # name — имя цели, url — адрес преленда (для проверки), prefix — префикс секретов _FTP_*, - # dir — каталог сайта на FTP ("" — корень FTP, как на beget). Новый домен — новая запись и три секрета; - # requisites.html создают на хостинге цели. Рабочий config.json остаётся - # на хостинге и обычным deploy не заменяется. - - name: barbariskatest - url: https://preland.anton.barbariskatest.ru - prefix: PRELAND - dir: "" - - defaults: - run: - shell: sh - - env: - PRELAND_URL: ${{ matrix.url }} - REMOTE_DIR: ${{ matrix.dir }} - # Последний успешно опубликованный legacy-снимок. На нестабильный FTP - # отправляем только изменения приложения после него, а не весь набор - # уже присутствующих медиафайлов. - LEGACY_DEPLOY_BASE: dfbb086e2b131a084f3de5b0f92c9cd69616c01a - - steps: - - name: Install tools - run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl lftp php-cli - - # На production runner образ задания не содержит Node.js, поэтому - # actions/checkout не запускается. Клонируем тот же main напрямую: - # это не меняет содержимое host config.json. - - name: Checkout production branch - run: git clone --branch main http://git.dira89kv.beget.tech:3000/anton/preland.git . - - - 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 - script=$(mktemp) - trap 'rm -f "$script"' EXIT - git diff --name-only "$LEGACY_DEPLOY_BASE"..HEAD > changed-files.txt - grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$|config\.json$|bin/|tests/)' \ - changed-files.txt > files.txt || true - # имя уходит в адрес ftp:// как есть — пробелы и прочее экранирование не поддерживаем - if grep -q '[^A-Za-z0-9._/-]' files.txt; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' files.txt; exit 1; fi - { - echo 'set cmd:fail-exit yes' - echo 'set ftp:ssl-force true' - echo 'set ftp:ssl-protect-data true' - echo 'set ssl:verify-certificate yes' - echo 'set net:max-retries 3' - echo 'set net:timeout 30' - } > "$script" - # Сервер legacy обрывает бинарные загрузки curl с 426. lftp ведёт - # каждую передачу отдельной FTPS-командой, не ослабляя TLS. - while read -r f; do - echo "загрузка: $f" - remote_dir=${f%/*} - [ "$remote_dir" = "$f" ] && remote_dir='.' - if [ "$remote_dir" != '.' ]; then - # Legacy-сервер возвращает 550 и для уже существующего каталога. - # Ошибку mkdir гасим, но put по-прежнему завершит выпуск ошибкой, - # если каталог действительно нельзя использовать. - printf 'mkdir -p %s%s || echo\n' "${dir:+$dir/}" "$remote_dir" >> "$script" - fi - printf 'put -O %s%s %s\n' \ - "${dir:+$dir/}" "$remote_dir" "$f" >> "$script" - done < files.txt - printf 'bye\n' >> "$script" - lftp -u "$FTP_USER","$FTP_PASS" -e "source $script" "ftp://$FTP_HOST" - 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' @@ -200,12 +62,26 @@ jobs: known_hosts=$(mktemp) batch=$(mktemp) files=$(mktemp) - trap 'rm -f "$known_hosts" "$batch" "$files"' EXIT + 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" - sed 's#/[^/]*$##' "$files" | grep -v '^$' | sort -u | while read -r dir; do printf '%s\n' "-mkdir $REMOTE_DIR/$dir" >> "$batch"; done + 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")" @@ -218,35 +94,12 @@ jobs: 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; } + curl -fsS --max-time 30 "$PRELAND_URL/1/01_desk_1-source.avif" -o /dev/null || { echo "не опубликован первый набор примеров"; exit 1; } + curl -fsS --max-time 30 "$PRELAND_URL/2/01_desk_1-source.avif" -o /dev/null || { echo "не опубликован второй набор примеров"; exit 1; } + page2=$(curl -fsS --max-time 30 "$PRELAND_URL/?indpic=2&nc=$(date +%s)") + printf '%s' "$page2" | grep -q '2/01_mob_1-source.avif' || { echo "indpic=2 не использует второй набор примеров"; exit 1; } - 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 + - name: Remove obsolete demo directories env: SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }} SFTP_USER: ${{ secrets.ZOOMPIXEL_SFTP_USER }} @@ -254,50 +107,18 @@ jobs: 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) - trap 'rm -f "$known_hosts" "$batch" "$files"' EXIT + trap 'rm -f "$known_hosts" "$batch"' 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" - sed 's#/[^/]*$##' "$files" | grep -v '^$' | sort -u | 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" + for set in demo demo-2; do + for example in 1 2 3 4 5; do + for file in source.avif source.jpg source.webp result.avif result.jpg result.webp thumb.avif thumb.jpg thumb.webp source_mob.avif result_mob.avif thumb_mob.avif; do + printf '%s\n' "-rm $REMOTE_DIR/images/$set/$example/$file" >> "$batch" + done + printf '%s\n' "-rmdir $REMOTE_DIR/images/$set/$example" >> "$batch" + done + 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" - 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; } diff --git a/AGENTS.md b/AGENTS.md index f7b25f9..e8373f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ такого выпуска фактический stage-config следует забрать и зафиксировать в Git. `requisites.html` также живёт только на хостинге и не перезаписывается. Не создавать target-specific конфиги или `config.json.example`. -- Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert` +- Коммит в `staging` запускает SFTP-автодеплой только на zoomstage.online; откат через `git revert` также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению. - Проверки кода и браузера проводить в Docker без установки ПО на хост. Проверка загрузки по боевому адресу вызывает реальную обработку; выполнять её только @@ -38,3 +38,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. diff --git a/README.md b/README.md index 7063eef..0fc47df 100644 --- a/README.md +++ b/README.md @@ -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`. ## Как работает @@ -91,10 +91,7 @@ Preland всегда формирует полный resolved-набор сво git clone http://git.dira89kv.beget.tech:3000/anton/preland.git && cd preland # дальше — загрузка на хостинг: rsync/scp по SSH или FTP-клиент ``` -3. **Конфиг и реквизиты.** Runtime `config.json` создаётся при первичной настройке хоста и не заменяется - обычной синхронизацией. Менять в нём можно только ключи, явно согласованные в задаче; после такого выпуска - актуальный stage-снимок фиксируется в Git. Реквизиты задаются ключом `requisites` либо runtime-файлом - `requisites.html`. +3. **Конфиг и реквизиты.** Рабочий `config.json` на хостинге — источник истины. При первичном создании домена его настраивают отдельно; обычный deploy не заменяет его репозиторным снимком и не перезаписывает `requisites.html`. Менять можно только явно согласованные ключи после чтения текущего файла. 4. **nginx без Apache** (если хостинг не обрабатывает `.htaccess`): ```nginx server { @@ -125,42 +122,25 @@ Preland всегда формирует полный resolved-набор сво ## Автодеплой (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. Один раз создать runtime `config.json` с нужными значениями: последующие обычные deploy его не перезапишут. -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`. -Перед разрешённым изменением конкретного конфигурационного ключа workflow считывает текущий host-файл и -патчит только этот ключ. -На хостинге ничего не удаляется: файлы, убранные из репозитория, удалять вручную. - -**Проверка после заливки.** На странице цели должен быть адрес загрузки на сайте, а не на самом преленде (иначе нет -`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. +Рабочие config.json/requisites.html на хостинге не перезаписываются. Корневые наборы +/N/ принадлежат stage и исключены из кодовой доставки. Родительские каталоги создаются +штатным SFTP-генератором; smoke проверяет страницу и assets наборов1/2. +Откат — отдельный согласованный revert в staging, который обновляет только stage. ## 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 не меняются. - diff --git a/tests/check-versioned-stage-config.php b/tests/check-versioned-stage-config.php index 20badda..2a3414c 100644 --- a/tests/check-versioned-stage-config.php +++ b/tests/check-versioned-stage-config.php @@ -36,30 +36,20 @@ if (!is_array($config)) { } $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."; - } +$stageStart = strpos($workflow, ' deploy-zoomstage:'); +$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 единственный.'; } @@ -69,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");