commit d80dd91769b5e1c4259a31a72b240d22576c30d3 Author: touche Date: Sat Oct 24 05:49:49 2026 +0300 Initial commit: WordPress plugins collection diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe18e15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.backup +.DS_Store +Thumbs.db diff --git a/S1-floating-video.zip b/S1-floating-video.zip new file mode 100644 index 0000000..22dbe86 Binary files /dev/null and b/S1-floating-video.zip differ diff --git a/S1-floating-video/assets/floating-video.css b/S1-floating-video/assets/floating-video.css new file mode 100644 index 0000000..9decb37 --- /dev/null +++ b/S1-floating-video/assets/floating-video.css @@ -0,0 +1,83 @@ +.floating-video-container { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 9999; +} + +.floating-video-mini { + width: 120px; + height: 180px; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 6px 20px rgba(0,0,0,0.35); + background: #000; + position: relative; +} + +.floating-video-mini video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.expand-btn, .close-btn { + position: absolute; + background: rgba(0,0,0,0.6); + color: white; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.expand-btn { bottom: 8px; right: 44px; } +.close-btn { bottom: 8px; right: 8px; } + +.expand-btn:hover, .close-btn:hover { + background: rgba(0,0,0,0.85); + transform: scale(1.15); +} + +/* Модальное окно */ +.video-modal { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.92); + z-index: 10000; + align-items: center; + justify-content: center; + padding: 20px; + box-sizing: border-box; +} + +.modal-content { + position: relative; + max-width: 96vw; + max-height: 96vh; +} + +.modal-content video { + max-width: 100%; + max-height: 90vh; + border-radius: 8px; +} + +.modal-close { + position: absolute; + top: -45px; + right: 0; + background: none; + border: none; + color: white; + font-size: 48px; + cursor: pointer; + line-height: 1; +} \ No newline at end of file diff --git a/S1-floating-video/assets/floating-video.js b/S1-floating-video/assets/floating-video.js new file mode 100644 index 0000000..bfaf55a --- /dev/null +++ b/S1-floating-video/assets/floating-video.js @@ -0,0 +1,35 @@ +document.addEventListener('DOMContentLoaded', () => { + const mini = document.getElementById('miniPlayer'); + const modal = document.getElementById('videoModal'); + const expandBtn = mini.querySelector('.expand-btn'); + const closeMini = mini.querySelector('.close-btn'); + const modalClose = modal.querySelector('.modal-close'); + + // Развернуть видео + expandBtn.addEventListener('click', () => { + modal.style.display = 'flex'; + // Можно раскомментировать, если хотите включить звук в полном экране: + // modal.querySelector('video').muted = false; + }); + + // Закрыть модальное окно + modalClose.addEventListener('click', () => { + modal.style.display = 'none'; + }); + + modal.addEventListener('click', e => { + if (e.target === modal) modal.style.display = 'none'; + }); + + // Полностью скрыть мини-плеер + closeMini.addEventListener('click', () => { + mini.style.display = 'none'; + }); + + // Запускаем видео в мини-окне (на всякий случай) + const miniVideo = mini.querySelector('video'); + miniVideo.play().catch(() => { + // Автозапуск заблокирован → почти всегда так в Chrome при первом посещении + console.log("Автозапуск заблокирован браузером"); + }); +}); \ No newline at end of file diff --git a/S1-floating-video/estetika-floating-video.php b/S1-floating-video/estetika-floating-video.php new file mode 100644 index 0000000..b6b26e2 --- /dev/null +++ b/S1-floating-video/estetika-floating-video.php @@ -0,0 +1,327 @@ + 'enabled'] + ); + + // Поле: URL видео + add_settings_field( + 'video_url', + 'URL видео (mp4)', + 'est_field_text_cb', + 'est-floating-video', + 'est_main_section', + ['id' => 'video_url', 'type' => 'url', 'placeholder' => 'https://.../video.mp4'] + ); + + // Поле: ширина мини-плеера + add_settings_field( + 'width', + 'Ширина мини-видео (px)', + 'est_field_number_cb', + 'est-floating-video', + 'est_main_section', + ['id' => 'width', 'min' => 80, 'max' => 300] + ); + + // Поле: высота мини-плеера + add_settings_field( + 'height', + 'Высота мини-видео (px)', + 'est_field_number_cb', + 'est-floating-video', + 'est_main_section', + ['id' => 'height', 'min' => 120, 'max' => 500] + ); + + // Поле: отступ снизу (bottom) + add_settings_field( + 'bottom_offset', + 'Отступ снизу (px) — полезно над чатом/кнопками', + 'est_field_number_cb', + 'est-floating-video', + 'est_main_section', + ['id' => 'bottom_offset', 'min' => 10, 'max' => 300] + ); + // Поле: показывать на мобильных +add_settings_field( + 'show_on_mobile', + 'Показывать на мобильных устройствах', + 'est_field_checkbox_cb', + 'est-floating-video', + 'est_main_section', + ['id' => 'show_on_mobile'] +); + + + +} +add_action('admin_init', 'est_floating_video_settings_init'); + +// Колбэки для полей +function est_main_section_callback() { + echo '

Настройте отображение плавающего промо-видео.

'; +} + +function est_field_checkbox_cb($args) { + $options = get_option('est_floating_video_options', []); + $value = isset($options[$args['id']]) ? $options[$args['id']] : '1'; + ?> + /> + + + + + px + +
+

+
+ +
+
+ true], 'names'); + + foreach ($post_types as $post_type) { + add_meta_box( + 'est_floating_video_meta', + 'Плавающее видео для этой записи', + 'est_floating_video_meta_box_callback', + $post_type, + 'side', + 'high' + ); + } +} +add_action('add_meta_boxes', 'est_floating_video_add_meta_box'); + +function est_floating_video_meta_box_callback($post) { + wp_nonce_field('est_floating_video_meta_nonce', 'est_floating_video_nonce'); + + $video_url = get_post_meta($post->ID, 'est_floating_video_url', true); + ?> +

+ +

+ + !empty($options['enabled']), + ]); +} +add_action('wp_enqueue_scripts', 'est_floating_video_enqueue'); + +function est_floating_video_output() { + $options = get_option('est_floating_video_options', []); + + // Глобальные настройки по умолчанию + $global_enabled = !empty($options['enabled']); + $show_on_mobile = isset($options['show_on_mobile']) ? (bool)$options['show_on_mobile'] : true; + $global_video_url = !empty($options['video_url']) ? $options['video_url'] : ''; + + // Определяем, нужно ли вообще показывать + if (!$global_enabled) { + return; + } + + // Проверка мобильного устройства + if (wp_is_mobile() && !$show_on_mobile) { + return; + } + + // Определяем видео: сначала индивидуальное, потом глобальное + $video_url = $global_video_url; + + if (is_singular()) { + $custom_url = get_post_meta(get_the_ID(), 'est_floating_video_url', true); + if (!empty($custom_url)) { + $video_url = $custom_url; + } + } + + // Если в итоге нет видео — выходим + if (empty($video_url)) { + return; + } + + $width = $options['width'] ?? 120; + $height = $options['height'] ?? 180; + $bottom = $options['bottom_offset'] ?? 20; + + $video_url = esc_url($video_url); + ?> +
+
+ + + +
+ +
+ +
+
+ + + + + + diff --git a/s1-custom-scroll-to-top/assets/path2.png b/s1-custom-scroll-to-top/assets/path2.png new file mode 100644 index 0000000..09da062 Binary files /dev/null and b/s1-custom-scroll-to-top/assets/path2.png differ diff --git a/s1-custom-scroll-to-top/assets/script.js b/s1-custom-scroll-to-top/assets/script.js new file mode 100644 index 0000000..6a011fa --- /dev/null +++ b/s1-custom-scroll-to-top/assets/script.js @@ -0,0 +1,32 @@ +jQuery(document).ready(function($) { + + const btn = $('#cstt-scroll-top'); + if (!btn.length) return; + + // Устанавливаем класс позиции + btn.addClass('cstt-' + csttData.position); + + // Устанавливаем изображение + const icon = btn.find('.cstt-icon'); + if (csttData.custom_img) { + icon.css('background-image', `url(${csttData.custom_img})`); + } else { + icon.css('background-image', `url(${csttData.default_img})`); + } + + // Показываем/скрываем кнопку + $(window).on('scroll', function() { + if ($(this).scrollTop() > 400) { + btn.addClass('show'); + } else { + btn.removeClass('show'); + } + }); + + // Клик → наверх + btn.on('click', function(e) { + e.preventDefault(); + $('html, body').animate({scrollTop: 0}, 500); + }); + +}); \ No newline at end of file diff --git a/s1-custom-scroll-to-top/assets/style.css b/s1-custom-scroll-to-top/assets/style.css new file mode 100644 index 0000000..9d9f263 --- /dev/null +++ b/s1-custom-scroll-to-top/assets/style.css @@ -0,0 +1,48 @@ +#cstt-scroll-top { + position: fixed; + z-index: 9999; + width: 50px; + height: 50px; + background: #333; + color: #fff; + border-radius: 50%; + box-shadow: 0 4px 10px rgba(0,0,0,0.25); + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; +} + +#cstt-scroll-top.show { + opacity: 0.85; + visibility: visible; +} + +#cstt-scroll-top:hover { + opacity: 1; + transform: scale(1.08); +} + +/* Позиции */ +.cstt-left-bottom { bottom: 25px; left: 25px; } +.cstt-right-bottom { bottom: 25px; right: 25px; } +.cstt-left-top { top: 25px; left: 25px; } +.cstt-right-top { top: 25px; right: 25px; } +.cstt-center-bottom { + bottom: 25px; + left: 50%; + transform: translateX(-50%); +} + +/* Иконка */ +.cstt-icon { + display: block; + width: 28px; + height: 28px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} \ No newline at end of file diff --git a/s1-custom-scroll-to-top/s1-custom-scroll-to-top.php b/s1-custom-scroll-to-top/s1-custom-scroll-to-top.php new file mode 100644 index 0000000..e9e2a75 --- /dev/null +++ b/s1-custom-scroll-to-top/s1-custom-scroll-to-top.php @@ -0,0 +1,218 @@ + $position, + 'custom_img' => $custom_img ? esc_url($custom_img) : '', + 'default_img' => CSTT_URL . 'assets/arrow-up.svg', + ]); +} +add_action('wp_enqueue_scripts', 'cstt_enqueue_frontend_assets'); + + +/** + * HTML кнопки (добавляем в footer) + */ +function cstt_render_button() { + ?> + + + + 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => 'right-bottom' + ]); + + register_setting('cstt_settings_group', 'cstt_custom_image', [ + 'type' => 'string', + 'sanitize_callback' => 'esc_url_raw', + ]); + + add_settings_section( + 'cstt_main_section', + 'Настройки кнопки «Наверх»', + '__return_false', + 'cstt-settings' + ); + + add_settings_field( + 'cstt_position', + 'Положение кнопки', + 'cstt_position_field_callback', + 'cstt-settings', + 'cstt_main_section' + ); + + add_settings_field( + 'cstt_custom_image', + 'Своя иконка (рекомендуется 64×64 px)', + 'cstt_image_field_callback', + 'cstt-settings', + 'cstt_main_section' + ); +} +add_action('admin_init', 'cstt_register_settings'); + + +function cstt_position_field_callback() { + $value = get_option('cstt_position', 'right-bottom'); + $positions = [ + 'left-bottom' => 'Левый нижний угол', + 'right-bottom' => 'Правый нижний угол (по умолчанию)', + 'left-top' => 'Левый верхний угол', + 'right-top' => 'Правый верхний угол', + 'center-bottom' => 'По центру снизу', + ]; + ?> + + +
+ + +

+ Вставьте прямую ссылку на изображение (PNG, JPG, SVG, WebP).
+ Рекомендуемый размер: 64×64 px или больше, квадратное, желательно с прозрачным фоном. +

+ +
+ Превью:
+ + + Изображение не отображается (проверьте ссылку) + +
+
+ + + +
+

Настройки кнопки «Наверх»

+
+ +
+
+ 404

Страница не найдена.

', + '404', + array( 'response' => 404 ) + ); + } + + /** Путь текущего запроса относительно корня сайта, без query-строки, в нижнем регистре, без слэшей по краям. */ + private static function get_request_path() { + $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : ''; + $path = wp_parse_url( $request_uri, PHP_URL_PATH ); + $path = is_string( $path ) ? $path : ''; + + $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH ); + $home_path = is_string( $home_path ) ? trim( $home_path, '/' ) : ''; + + $path = trim( $path, '/' ); + + // Если сайт установлен в подпапке — отрезаем этот префикс от пути запроса. + if ( $home_path && 0 === strpos( $path, $home_path ) ) { + $path = trim( substr( $path, strlen( $home_path ) ), '/' ); + } + + return strtolower( $path ); + } + + private static function is_wp_admin_path( $path ) { + return 'wp-admin' === $path || 0 === strpos( $path, 'wp-admin/' ); + } + + /* ------------------------------------------------------------------ */ + /* Подмена ссылок, которые генерирует само ядро WordPress */ + /* ------------------------------------------------------------------ */ + + /** + * wp_login_url(), wp_logout_url(), wp_registration_url(), wp_lostpassword_url() + * в ядре всегда вызывают site_url('wp-login.php', 'login'|'login_post') — + * подменяем результат на наш слаг, чтобы нигде на сайте не светился + * настоящий адрес wp-login.php. + */ + public static function filter_login_url( $url, $path, $scheme = null ) { + if ( in_array( $scheme, array( 'login', 'login_post' ), true ) ) { + $url = str_replace( 'wp-login.php', self::get_slug(), $url ); + } + return $url; + } + + /* ------------------------------------------------------------------ */ + /* Настройки */ + /* ------------------------------------------------------------------ */ + + public static function get_slug() { + $slug = get_option( self::OPTION_KEY, self::DEFAULT_SLUG ); + return $slug ? $slug : self::DEFAULT_SLUG; + } + + public static function add_settings_page() { + add_options_page( + 'S1 Hide Admin', + 'S1 Hide Admin', + 'manage_options', + 's1-hide-admin', + array( __CLASS__, 'render_settings_page' ) + ); + } + + public static function maybe_save_settings() { + if ( empty( $_POST['s1_hide_admin_nonce'] ) || ! wp_verify_nonce( $_POST['s1_hide_admin_nonce'], self::NONCE_ACTION ) ) { + return; + } + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $slug = isset( $_POST['s1_hide_admin_slug'] ) ? sanitize_title( wp_unslash( $_POST['s1_hide_admin_slug'] ) ) : ''; + + // Запрещаем очевидно ломающие значения: пустое, wp-admin, wp-login(.php). + $forbidden = array( '', 'wp-admin', 'wp-login', 'wp-login.php' ); + if ( in_array( $slug, $forbidden, true ) ) { + add_action( 'admin_notices', function () { + echo '

S1 Hide Admin: этот слаг использовать нельзя (пусто, wp-admin или wp-login). Настройка не сохранена.

'; + } ); + return; + } + + update_option( self::OPTION_KEY, $slug ); + + add_action( 'admin_notices', function () { + echo '

Адрес входа обновлён.

'; + } ); + } + + public static function render_settings_page() { + $slug = self::get_slug(); + $login_url = home_url( '/' . $slug ); + ?> +
+

S1 Hide Admin

+

Стандартные адреса wp-login.php и /wp-admin/ для неавторизованных посетителей скрыты — вместо них показывается обычная страница 404. Вход в панель управления возможен только по адресу ниже.

+ +
+

Важно: сохраните этот адрес, прежде чем закрывать страницу. Если вы забудете новый слаг, попасть в админку через обычный wp-login.php не получится — восстановить доступ можно будет только через переименование/удаление файла плагина по FTP или через файловый менеджер хостинга.

+
+ + + + + + + + +
+ + + + + + + + +
+
+ element showing the count. + * @param {number} len Current character length. + * @param {number} soft Ideal upper boundary (turns amber above this). + * @param {number} hard Hard limit (turns red at or above this). + */ + function updateCounter( $counter, len, soft, hard ) { + $counter.text( len ); + $counter + .removeClass( 's1-count-ok s1-count-warn s1-count-over' ) + .addClass( + len >= hard ? 's1-count-over' : + len >= soft ? 's1-count-warn' : + len > 0 ? 's1-count-ok' : '' + ); + } + + function initCounters() { + // Title field + var $titleInput = $( '#s1_seo_title' ); + var $titleCounter = $( '#s1_seo_title_count' ); + + if ( $titleInput.length && $titleCounter.length ) { + // Initialise on page load. + updateCounter( $titleCounter, $titleInput.val().length, 50, 60 ); + + $titleInput.on( 'input', function () { + updateCounter( $titleCounter, $( this ).val().length, 50, 60 ); + } ); + } + + // Description field + var $descInput = $( '#s1_seo_description' ); + var $descCounter = $( '#s1_seo_desc_count' ); + + if ( $descInput.length && $descCounter.length ) { + updateCounter( $descCounter, $descInput.val().length, 120, 160 ); + + $descInput.on( 'input', function () { + updateCounter( $descCounter, $( this ).val().length, 120, 160 ); + } ); + } + } + + $( document ).ready( initCounters ); + +} )( jQuery ); diff --git a/s1-seo-meta73/includes/class-s1-seo-meta-output.php b/s1-seo-meta73/includes/class-s1-seo-meta-output.php new file mode 100644 index 0000000..09e6039 --- /dev/null +++ b/s1-seo-meta73/includes/class-s1-seo-meta-output.php @@ -0,0 +1,109 @@ + and tags in the . + * + * @package S1_SEO_Meta + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +class S1_SEO_Meta_Output { + + public function __construct() { + // Output meta tags as early as possible in wp_head. + add_action( 'wp_head', array( $this, 'output_meta_tags' ), 1 ); + + // Override the document tag via wp_title (classic themes). + add_filter( 'wp_title', array( $this, 'filter_wp_title' ), 20, 1 ); + + // Override the document <title> tag via document_title_parts (block / modern themes). + add_filter( 'document_title_parts', array( $this, 'filter_document_title_parts' ), 20 ); + } + + /** + * Determine the SEO title and description for the current page and echo them. + */ + public function output_meta_tags() { + $seo_title = $this->get_current_title(); + $seo_desc = $this->get_current_description(); + + if ( $seo_desc ) { + echo '<meta name="description" content="' . esc_attr( $seo_desc ) . '" />' . "\n"; + } + } + + /** + * Filter wp_title() for classic themes that use it. + * + * @param string $title Current title string. + * @return string + */ + public function filter_wp_title( $title ) { + $seo_title = $this->get_current_title(); + return $seo_title ? $seo_title : $title; + } + + /** + * Filter document_title_parts for modern themes that use add_theme_support('title-tag'). + * + * @param array $parts Title parts array. + * @return array + */ + public function filter_document_title_parts( $parts ) { + $seo_title = $this->get_current_title(); + + if ( $seo_title ) { + // Replace the 'title' part; remove 'site' to avoid duplication. + $parts['title'] = $seo_title; + unset( $parts['tagline'], $parts['site'] ); + } + + return $parts; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Return the SEO title for whatever is currently being displayed. + * + * @return string Empty string when no custom title is set. + */ + private function get_current_title() { + if ( is_singular() ) { + return S1_SEO_Meta_Post::get_title( get_the_ID() ); + } + + if ( is_category() || is_tag() || is_tax() ) { + $term = get_queried_object(); + if ( $term instanceof WP_Term ) { + return S1_SEO_Meta_Term::get_title( $term->term_id ); + } + } + + return ''; + } + + /** + * Return the SEO description for whatever is currently being displayed. + * + * @return string Empty string when no custom description is set. + */ + private function get_current_description() { + if ( is_singular() ) { + return S1_SEO_Meta_Post::get_description( get_the_ID() ); + } + + if ( is_category() || is_tag() || is_tax() ) { + $term = get_queried_object(); + if ( $term instanceof WP_Term ) { + return S1_SEO_Meta_Term::get_description( $term->term_id ); + } + } + + return ''; + } +} diff --git a/s1-seo-meta73/includes/class-s1-seo-meta-post.php b/s1-seo-meta73/includes/class-s1-seo-meta-post.php new file mode 100644 index 0000000..9bc8420 --- /dev/null +++ b/s1-seo-meta73/includes/class-s1-seo-meta-post.php @@ -0,0 +1,164 @@ +<?php +/** + * Handles saving and retrieving SEO meta for posts, pages and custom post types. + * + * @package S1_SEO_Meta + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +class S1_SEO_Meta_Post { + + /** + * Meta key for SEO title. + */ + const META_TITLE = '_s1_seo_title'; + + /** + * Meta key for SEO description. + */ + const META_DESC = '_s1_seo_description'; + + public function __construct() { + add_action( 'add_meta_boxes', array( $this, 'add_meta_box' ) ); + add_action( 'save_post', array( $this, 'save_meta' ), 10, 2 ); + } + + /** + * Register the meta box for all public post types. + */ + public function add_meta_box() { + $post_types = get_post_types( array( 'public' => true ), 'names' ); + + foreach ( $post_types as $post_type ) { + add_meta_box( + 's1-seo-meta-box', + __( 'S1 SEO: Title & Description', 's1-seo-meta' ), + array( $this, 'render_meta_box' ), + $post_type, + 'normal', + 'high' + ); + } + } + + /** + * Render the meta box HTML. + * + * @param WP_Post $post Current post object. + */ + public function render_meta_box( $post ) { + wp_nonce_field( 's1_seo_meta_save', 's1_seo_meta_nonce' ); + + $seo_title = get_post_meta( $post->ID, self::META_TITLE, true ); + $seo_desc = get_post_meta( $post->ID, self::META_DESC, true ); + ?> + <div class="s1-seo-wrap"> + <div class="s1-seo-field"> + <label for="s1_seo_title"> + <?php esc_html_e( 'SEO Title', 's1-seo-meta' ); ?> + </label> + <input + type="text" + id="s1_seo_title" + name="s1_seo_title" + value="<?php echo esc_attr( $seo_title ); ?>" + placeholder="<?php echo esc_attr( get_the_title( $post->ID ) ); ?>" + maxlength="666" + /> + <div class="s1-seo-counter"> + <span class="s1-seo-count" id="s1_seo_title_count">0</span>/60 + <?php esc_html_e( 'characters (recommended: 50–60)', 's1-seo-meta' ); ?> + </div> + </div> + + <div class="s1-seo-field"> + <label for="s1_seo_description"> + <?php esc_html_e( 'SEO Description', 's1-seo-meta' ); ?> + </label> + <textarea + id="s1_seo_description" + name="s1_seo_description" + rows="3" + maxlength="666" + placeholder="<?php esc_attr_e( 'Write a short description of this page…', 's1-seo-meta' ); ?>" + ><?php echo esc_textarea( $seo_desc ); ?></textarea> + <div class="s1-seo-counter"> + <span class="s1-seo-count" id="s1_seo_desc_count">0</span>/160 + <?php esc_html_e( 'characters (recommended: 120–160)', 's1-seo-meta' ); ?> + </div> + </div> + </div> + <?php + } + + /** + * Save SEO meta fields when the post is saved. + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + */ + public function save_meta( $post_id, $post ) { + // Verify nonce. + if ( + ! isset( $_POST['s1_seo_meta_nonce'] ) || + ! wp_verify_nonce( sanitize_key( $_POST['s1_seo_meta_nonce'] ), 's1_seo_meta_save' ) + ) { + return; + } + + // Skip autosaves and revisions. + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + return; + } + if ( wp_is_post_revision( $post_id ) ) { + return; + } + + // Check permissions. + $post_type_obj = get_post_type_object( $post->post_type ); + if ( ! current_user_can( $post_type_obj->cap->edit_post, $post_id ) ) { + return; + } + + // Save title. + if ( isset( $_POST['s1_seo_title'] ) ) { + update_post_meta( + $post_id, + self::META_TITLE, + sanitize_text_field( wp_unslash( $_POST['s1_seo_title'] ) ) + ); + } + + // Save description. + if ( isset( $_POST['s1_seo_description'] ) ) { + update_post_meta( + $post_id, + self::META_DESC, + sanitize_textarea_field( wp_unslash( $_POST['s1_seo_description'] ) ) + ); + } + } + + /** + * Static helper: retrieve SEO title for a given post. + * + * @param int $post_id Post ID. + * @return string + */ + public static function get_title( $post_id ) { + return (string) get_post_meta( $post_id, self::META_TITLE, true ); + } + + /** + * Static helper: retrieve SEO description for a given post. + * + * @param int $post_id Post ID. + * @return string + */ + public static function get_description( $post_id ) { + return (string) get_post_meta( $post_id, self::META_DESC, true ); + } +} diff --git a/s1-seo-meta73/includes/class-s1-seo-meta-term.php b/s1-seo-meta73/includes/class-s1-seo-meta-term.php new file mode 100644 index 0000000..fc73b87 --- /dev/null +++ b/s1-seo-meta73/includes/class-s1-seo-meta-term.php @@ -0,0 +1,196 @@ +<?php +/** + * Handles saving and retrieving SEO meta for taxonomy terms + * (categories, tags, and all public custom taxonomies). + * + * @package S1_SEO_Meta + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +class S1_SEO_Meta_Term { + + /** + * Option-table key prefix used to store term meta (WP < 4.4 fallback aware). + * From WP 4.4+ term meta is stored in wp_termmeta via get/update_term_meta(). + */ + const META_TITLE = 's1_seo_title'; + const META_DESC = 's1_seo_description'; + + public function __construct() { + add_action( 'init', array( $this, 'register_term_hooks' ) ); + } + + /** + * Register add/edit/save hooks for every public taxonomy. + */ + public function register_term_hooks() { + $taxonomies = get_taxonomies( array( 'public' => true ), 'names' ); + + foreach ( $taxonomies as $taxonomy ) { + // Fields on the "Add new term" form. + add_action( "{$taxonomy}_add_form_fields", array( $this, 'add_form_fields' ) ); + // Fields on the "Edit term" form. + add_action( "{$taxonomy}_edit_form_fields", array( $this, 'edit_form_fields' ), 10, 2 ); + // Save on create. + add_action( "created_{$taxonomy}", array( $this, 'save_term_meta' ), 10, 2 ); + // Save on update. + add_action( "edited_{$taxonomy}", array( $this, 'save_term_meta' ), 10, 2 ); + } + } + + /** + * Render fields on the "Add new term" screen. + * + * @param string $taxonomy Current taxonomy slug. + */ + public function add_form_fields( $taxonomy ) { + wp_nonce_field( 's1_seo_term_save', 's1_seo_term_nonce' ); + ?> + <div class="form-field s1-seo-term-wrap"> + <h3 class="s1-seo-term-heading"><?php esc_html_e( 'S1 SEO', 's1-seo-meta' ); ?></h3> + + <div class="form-field"> + <label for="s1_seo_title"><?php esc_html_e( 'SEO Title', 's1-seo-meta' ); ?></label> + <input + type="text" + id="s1_seo_title" + name="s1_seo_title" + value="" + maxlength="666" + /> + <p class="s1-seo-counter"> + <span id="s1_seo_title_count">0</span>/60 <?php esc_html_e( 'characters', 's1-seo-meta' ); ?> + </p> + </div> + + <div class="form-field"> + <label for="s1_seo_description"><?php esc_html_e( 'SEO Description', 's1-seo-meta' ); ?></label> + <textarea + id="s1_seo_description" + name="s1_seo_description" + rows="3" + maxlength="666" + ></textarea> + <p class="s1-seo-counter"> + <span id="s1_seo_desc_count">0</span>/160 <?php esc_html_e( 'characters', 's1-seo-meta' ); ?> + </p> + </div> + </div> + <?php + } + + /** + * Render fields on the "Edit term" screen. + * + * @param WP_Term $term Current term object. + * @param string $taxonomy Current taxonomy slug. + */ + public function edit_form_fields( $term, $taxonomy ) { + wp_nonce_field( 's1_seo_term_save', 's1_seo_term_nonce' ); + + $seo_title = get_term_meta( $term->term_id, self::META_TITLE, true ); + $seo_desc = get_term_meta( $term->term_id, self::META_DESC, true ); + ?> + <tr class="form-field s1-seo-term-row"> + <td colspan="2"> + <h3 class="s1-seo-term-heading"><?php esc_html_e( 'S1 SEO', 's1-seo-meta' ); ?></h3> + </td> + </tr> + + <tr class="form-field"> + <th scope="row"> + <label for="s1_seo_title"><?php esc_html_e( 'SEO Title', 's1-seo-meta' ); ?></label> + </th> + <td> + <input + type="text" + id="s1_seo_title" + name="s1_seo_title" + value="<?php echo esc_attr( $seo_title ); ?>" + placeholder="<?php echo esc_attr( $term->name ); ?>" + maxlength="60" + /> + <p class="description s1-seo-counter"> + <span id="s1_seo_title_count"><?php echo esc_html( mb_strlen( $seo_title ) ); ?></span>/60 + <?php esc_html_e( 'characters (recommended: 50–60)', 's1-seo-meta' ); ?> + </p> + </td> + </tr> + + <tr class="form-field"> + <th scope="row"> + <label for="s1_seo_description"><?php esc_html_e( 'SEO Description', 's1-seo-meta' ); ?></label> + </th> + <td> + <textarea + id="s1_seo_description" + name="s1_seo_description" + rows="3" + maxlength="160" + ><?php echo esc_textarea( $seo_desc ); ?></textarea> + <p class="description s1-seo-counter"> + <span id="s1_seo_desc_count"><?php echo esc_html( mb_strlen( $seo_desc ) ); ?></span>/160 + <?php esc_html_e( 'characters (recommended: 120–160)', 's1-seo-meta' ); ?> + </p> + </td> + </tr> + <?php + } + + /** + * Save term SEO meta. + * + * @param int $term_id Term ID. + */ + public function save_term_meta( $term_id ) { + if ( + ! isset( $_POST['s1_seo_term_nonce'] ) || + ! wp_verify_nonce( sanitize_key( $_POST['s1_seo_term_nonce'] ), 's1_seo_term_save' ) + ) { + return; + } + + if ( ! current_user_can( 'manage_categories' ) ) { + return; + } + + if ( isset( $_POST['s1_seo_title'] ) ) { + update_term_meta( + $term_id, + self::META_TITLE, + sanitize_text_field( wp_unslash( $_POST['s1_seo_title'] ) ) + ); + } + + if ( isset( $_POST['s1_seo_description'] ) ) { + update_term_meta( + $term_id, + self::META_DESC, + sanitize_textarea_field( wp_unslash( $_POST['s1_seo_description'] ) ) + ); + } + } + + /** + * Static helper: retrieve SEO title for a term. + * + * @param int $term_id Term ID. + * @return string + */ + public static function get_title( $term_id ) { + return (string) get_term_meta( $term_id, self::META_TITLE, true ); + } + + /** + * Static helper: retrieve SEO description for a term. + * + * @param int $term_id Term ID. + * @return string + */ + public static function get_description( $term_id ) { + return (string) get_term_meta( $term_id, self::META_DESC, true ); + } +} diff --git a/s1-seo-meta73/languages/s1-seo-meta.pot b/s1-seo-meta73/languages/s1-seo-meta.pot new file mode 100644 index 0000000..edc23d6 --- /dev/null +++ b/s1-seo-meta73/languages/s1-seo-meta.pot @@ -0,0 +1,65 @@ +# Copyright (C) 2024 S1 +# This file is distributed under the same license as the S1 SEO Meta plugin. +msgid "" +msgstr "" +"Project-Id-Version: S1 SEO Meta 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-01-01 00:00:00+00:00\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"X-Generator: WP-CLI\n" +"X-Domain: s1-seo-meta\n" + +#. Plugin Name of the plugin +msgid "S1 SEO Meta" +msgstr "" + +#. Plugin URI of the plugin +msgid "https://github.com/S1/s1-seo-meta" +msgstr "" + +#. Description of the plugin +msgid "Добавляет мета-теги Title и Description для страниц, публикаций, рубрик, категорий и других типов записей." +msgstr "" + +#. Author of the plugin +msgid "S1" +msgstr "" + +#: includes/class-s1-seo-meta-post.php +msgid "S1 SEO: Title & Description" +msgstr "" + +#: includes/class-s1-seo-meta-post.php +#: includes/class-s1-seo-meta-term.php +msgid "SEO Title" +msgstr "" + +#: includes/class-s1-seo-meta-post.php +#: includes/class-s1-seo-meta-term.php +msgid "SEO Description" +msgstr "" + +#: includes/class-s1-seo-meta-post.php +msgid "characters (recommended: 50–60)" +msgstr "" + +#: includes/class-s1-seo-meta-post.php +msgid "characters (recommended: 120–160)" +msgstr "" + +#: includes/class-s1-seo-meta-post.php +msgid "Write a short description of this page…" +msgstr "" + +#: includes/class-s1-seo-meta-term.php +msgid "S1 SEO" +msgstr "" + +#: includes/class-s1-seo-meta-term.php +msgid "characters" +msgstr "" diff --git a/s1-seo-meta73/readme.txt b/s1-seo-meta73/readme.txt new file mode 100644 index 0000000..dbabd91 --- /dev/null +++ b/s1-seo-meta73/readme.txt @@ -0,0 +1,48 @@ +=== S1 SEO Meta === +Contributors: s1 +Tags: seo, meta, title, description, meta tags +Requires at least: 5.8 +Tested up to: 6.5 +Requires PHP: 7.4 +Stable tag: 1.0.0 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html + +Adds custom SEO Title and Description meta fields to posts, pages, custom post types, categories, tags, and all public taxonomies. + +== Description == + +S1 SEO Meta adds a lightweight, no-bloat SEO meta box to every post/page editor and taxonomy term screen in the WordPress admin. Simply fill in your desired **SEO Title** and **SEO Description** and the plugin will: + +* Output `<meta name="description">` in the `<head>`. +* Override the page `<title>` tag (supports both classic `wp_title` and modern `document_title_parts` hooks). +* Show a live character counter with colour-coded feedback (green / amber / red). + +Works with posts, pages, custom post types, categories, tags, and all registered public taxonomies. + +== Installation == + +1. Upload the `s1-seo-meta` folder to `/wp-content/plugins/`. +2. Activate the plugin from the **Plugins** menu in WordPress admin. +3. Open any post, page, or term — the **S1 SEO: Title & Description** box will appear. + +== Frequently Asked Questions == + += Will this conflict with Yoast SEO or Rank Math? = +It may conflict if both plugins try to output a `<meta name="description">`. It is recommended to use only one SEO plugin at a time. + += Does it support custom post types? = +Yes. The meta box is added automatically to every post type registered with `public => true`. + += Does it support custom taxonomies? = +Yes. Fields appear on every taxonomy registered with `public => true`. + +== Changelog == + += 1.0.0 = +* Initial release. + +== Upgrade Notice == + += 1.0.0 = +First release. diff --git a/s1-seo-meta73/s1-seo-meta.php b/s1-seo-meta73/s1-seo-meta.php new file mode 100644 index 0000000..2dc6bfc --- /dev/null +++ b/s1-seo-meta73/s1-seo-meta.php @@ -0,0 +1,56 @@ +<?php +/** + * Plugin Name: S1 SEO Meta + * Plugin URI: https://1studi.ru + * Description: Добавляет мета-теги Title и Description для страниц, публикаций, рубрик, категорий и других типов записей. + * Version: 1.0.1 + * Requires at least: 5.8 + * Requires PHP: 7.3 + * Author: S1 + * License: GPL v2 or later + * License URI: https://www.gnu.org/licenses/gpl-2.0.html + * Text Domain: s1-seo-meta + * Domain Path: /languages + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +define( 'S1_SEO_META_VERSION', '1.0.0' ); +define( 'S1_SEO_META_FILE', __FILE__ ); +define( 'S1_SEO_META_DIR', plugin_dir_path( __FILE__ ) ); +define( 'S1_SEO_META_URL', plugin_dir_url( __FILE__ ) ); + +/** + * Load plugin text domain for translations. + */ +function s1_seo_meta_load_textdomain() { + load_plugin_textdomain( 's1-seo-meta', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); +} +add_action( 'plugins_loaded', 's1_seo_meta_load_textdomain' ); + +/** + * Load required files. + */ +require_once S1_SEO_META_DIR . 'includes/class-s1-seo-meta-post.php'; +require_once S1_SEO_META_DIR . 'includes/class-s1-seo-meta-term.php'; +require_once S1_SEO_META_DIR . 'includes/class-s1-seo-meta-output.php'; + +if ( is_admin() ) { + require_once S1_SEO_META_DIR . 'admin/class-s1-seo-meta-admin.php'; +} + +/** + * Initialise all plugin components. + */ +function s1_seo_meta_init() { + new S1_SEO_Meta_Post(); + new S1_SEO_Meta_Term(); + new S1_SEO_Meta_Output(); + + if ( is_admin() ) { + new S1_SEO_Meta_Admin(); + } +} +add_action( 'init', 's1_seo_meta_init' ); diff --git a/s1-seo-meta73/s1-seo-meta73.zip b/s1-seo-meta73/s1-seo-meta73.zip new file mode 100644 index 0000000..e6560c2 Binary files /dev/null and b/s1-seo-meta73/s1-seo-meta73.zip differ diff --git a/s1-seo-meta74.zip b/s1-seo-meta74.zip new file mode 100644 index 0000000..135c555 Binary files /dev/null and b/s1-seo-meta74.zip differ diff --git a/s1-seo-meta74/admin/class-s1-seo-meta-admin.php b/s1-seo-meta74/admin/class-s1-seo-meta-admin.php new file mode 100644 index 0000000..dbb10ec --- /dev/null +++ b/s1-seo-meta74/admin/class-s1-seo-meta-admin.php @@ -0,0 +1,45 @@ +<?php +/** + * Admin-only bootstrap: enqueues CSS and JS for the meta box and term forms. + * + * @package S1_SEO_Meta + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +class S1_SEO_Meta_Admin { + + public function __construct() { + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * Enqueue admin styles and scripts on relevant screens. + * + * @param string $hook_suffix Current admin page hook. + */ + public function enqueue_assets( $hook_suffix ) { + $screens = array( 'post.php', 'post-new.php', 'edit-tags.php', 'term.php' ); + + if ( ! in_array( $hook_suffix, $screens, true ) ) { + return; + } + + wp_enqueue_style( + 's1-seo-meta-admin', + S1_SEO_META_URL . 'admin/css/s1-seo-meta-admin.css', + array(), + S1_SEO_META_VERSION + ); + + wp_enqueue_script( + 's1-seo-meta-admin', + S1_SEO_META_URL . 'admin/js/s1-seo-meta-admin.js', + array( 'jquery' ), + S1_SEO_META_VERSION, + true + ); + } +} diff --git a/s1-seo-meta74/admin/css/s1-seo-meta-admin.css b/s1-seo-meta74/admin/css/s1-seo-meta-admin.css new file mode 100644 index 0000000..7876905 --- /dev/null +++ b/s1-seo-meta74/admin/css/s1-seo-meta-admin.css @@ -0,0 +1,108 @@ +/* ========================================================================== + S1 SEO Meta — Admin Styles + ========================================================================== */ + +/* ---- Meta box wrapper --------------------------------------------------- */ +.s1-seo-wrap { + padding: 4px 0; +} + +.s1-seo-field { + margin-bottom: 16px; +} + +.s1-seo-field:last-child { + margin-bottom: 0; +} + +/* ---- Labels ------------------------------------------------------------- */ +.s1-seo-field label, +.s1-seo-term-heading { + display: block; + font-weight: 600; + font-size: 13px; + margin-bottom: 6px; + color: #1d2327; +} + +.s1-seo-term-heading { + font-size: 14px; + border-bottom: 1px solid #ddd; + padding-bottom: 8px; + margin-top: 4px; +} + +/* ---- Inputs ------------------------------------------------------------- */ +.s1-seo-field input[type="text"], +.s1-seo-field textarea { + width: 100%; + max-width: 100%; + box-sizing: border-box; + border: 1px solid #8c8f94; + border-radius: 4px; + padding: 8px 10px; + font-size: 13px; + line-height: 1.5; + color: #1d2327; + background: #fff; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +.s1-seo-field input[type="text"]:focus, +.s1-seo-field textarea:focus { + border-color: #2271b1; + box-shadow: 0 0 0 1px #2271b1; + outline: none; +} + +.s1-seo-field textarea { + resize: vertical; + min-height: 70px; +} + +/* ---- Character counter -------------------------------------------------- */ +.s1-seo-counter { + font-size: 11px; + color: #646970; + margin-top: 5px; +} + +.s1-seo-count { + font-weight: 600; +} + +.s1-seo-count.s1-count-ok { + color: #00a32a; /* green */ +} + +.s1-seo-count.s1-count-warn { + color: #dba617; /* amber */ +} + +.s1-seo-count.s1-count-over { + color: #d63638; /* red */ +} + +/* ---- Term form tweaks --------------------------------------------------- */ +.s1-seo-term-row td { + padding-bottom: 0 !important; +} + +#s1-seo-meta-box .inside { + padding: 12px; +} + +/* ---- wp_editor styles for term forms ----------------------------------- */ +.s1-seo-term-wrap .wp-editor-container { + border: 1px solid #8c8f94; + border-radius: 4px; + margin-top: 5px; +} + +.s1-seo-term-wrap .wp-editor-area { + min-height: 100px; +} + +.s1-seo-term-wrap .mce-toolbar { + border-bottom: 1px solid #ddd !important; +} diff --git a/s1-seo-meta74/admin/js/s1-seo-meta-admin.js b/s1-seo-meta74/admin/js/s1-seo-meta-admin.js new file mode 100644 index 0000000..7f671de --- /dev/null +++ b/s1-seo-meta74/admin/js/s1-seo-meta-admin.js @@ -0,0 +1,111 @@ +/** + * S1 SEO Meta — Admin Scripts + * Live character counter with colour feedback. + * Supports both regular textareas and TinyMCE editors (wp_editor). + */ +( function ( $ ) { + 'use strict'; + + /** + * Update a counter element with the current length and apply colour classes. + * + * @param {jQuery} $counter The <span> element showing the count. + * @param {number} len Current character length. + * @param {number} soft Ideal upper boundary (turns amber above this). + * @param {number} hard Hard limit (turns red at or above this). + */ + function updateCounter( $counter, len, soft, hard ) { + $counter.text( len ); + $counter + .removeClass( 's1-count-ok s1-count-warn s1-count-over' ) + .addClass( + len >= hard ? 's1-count-over' : + len >= soft ? 's1-count-warn' : + len > 0 ? 's1-count-ok' : '' + ); + } + + /** + * Get content from TinyMCE editor or fallback to textarea. + * + * @param {string} editorId The ID of the editor/textarea. + * @return {string} The editor content. + */ + function getEditorContent( editorId ) { + if ( typeof tinymce !== 'undefined' && tinymce.get( editorId ) ) { + return tinymce.get( editorId ).getContent(); + } + return $( '#' + editorId ).val() || ''; + } + + /** + * Count characters in HTML content (strips tags). + * + * @param {string} html HTML content. + * @return {number} Character count without HTML tags. + */ + function countTextChars( html ) { + var tempDiv = document.createElement( 'div' ); + tempDiv.innerHTML = html; + return tempDiv.textContent || tempDiv.innerText || ''; + } + + function initCounters() { + // Title field (always regular input) + var $titleInput = $( '#s1_seo_title' ); + var $titleCounter = $( '#s1_seo_title_count' ); + + if ( $titleInput.length && $titleCounter.length ) { + // Initialise on page load. + updateCounter( $titleCounter, $titleInput.val().length, 50, 60 ); + + $titleInput.on( 'input', function () { + updateCounter( $titleCounter, $( this ).val().length, 50, 60 ); + } ); + } + + // Description field (may be TinyMCE or textarea) + var $descCounter = $( '#s1_seo_desc_count' ); + + if ( $descCounter.length ) { + var editorId = 's1_seo_description'; + var initialContent = getEditorContent( editorId ); + var initialCount = countTextChars( initialContent ).length; + + // Initialise on page load. + updateCounter( $descCounter, initialCount, 120, 160 ); + + // Handle TinyMCE editor changes + if ( typeof tinymce !== 'undefined' ) { + tinymce.on( 'AddEditor', function( e ) { + if ( e.editor.id === editorId ) { + e.editor.on( 'keyup change', function() { + var content = e.editor.getContent(); + var count = countTextChars( content ).length; + updateCounter( $descCounter, count, 120, 160 ); + } ); + } + } ); + + // If editor already exists + if ( tinymce.get( editorId ) ) { + tinymce.get( editorId ).on( 'keyup change', function() { + var content = tinymce.get( editorId ).getContent(); + var count = countTextChars( content ).length; + updateCounter( $descCounter, count, 120, 160 ); + } ); + } + } + + // Fallback for regular textarea + $( '#' + editorId ).on( 'input', function () { + var content = $( this ).val(); + var count = countTextChars( content ).length; + updateCounter( $descCounter, count, 120, 160 ); + } ); + } + } + + $( document ).ready( initCounters ); + +} )( jQuery ); diff --git a/s1-seo-meta74/includes/class-s1-seo-meta-output.php b/s1-seo-meta74/includes/class-s1-seo-meta-output.php new file mode 100644 index 0000000..09e6039 --- /dev/null +++ b/s1-seo-meta74/includes/class-s1-seo-meta-output.php @@ -0,0 +1,109 @@ +<?php +/** + * Outputs <title> and <meta name="description"> tags in the <head>. + * + * @package S1_SEO_Meta + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +class S1_SEO_Meta_Output { + + public function __construct() { + // Output meta tags as early as possible in wp_head. + add_action( 'wp_head', array( $this, 'output_meta_tags' ), 1 ); + + // Override the document <title> tag via wp_title (classic themes). + add_filter( 'wp_title', array( $this, 'filter_wp_title' ), 20, 1 ); + + // Override the document <title> tag via document_title_parts (block / modern themes). + add_filter( 'document_title_parts', array( $this, 'filter_document_title_parts' ), 20 ); + } + + /** + * Determine the SEO title and description for the current page and echo them. + */ + public function output_meta_tags() { + $seo_title = $this->get_current_title(); + $seo_desc = $this->get_current_description(); + + if ( $seo_desc ) { + echo '<meta name="description" content="' . esc_attr( $seo_desc ) . '" />' . "\n"; + } + } + + /** + * Filter wp_title() for classic themes that use it. + * + * @param string $title Current title string. + * @return string + */ + public function filter_wp_title( $title ) { + $seo_title = $this->get_current_title(); + return $seo_title ? $seo_title : $title; + } + + /** + * Filter document_title_parts for modern themes that use add_theme_support('title-tag'). + * + * @param array $parts Title parts array. + * @return array + */ + public function filter_document_title_parts( $parts ) { + $seo_title = $this->get_current_title(); + + if ( $seo_title ) { + // Replace the 'title' part; remove 'site' to avoid duplication. + $parts['title'] = $seo_title; + unset( $parts['tagline'], $parts['site'] ); + } + + return $parts; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Return the SEO title for whatever is currently being displayed. + * + * @return string Empty string when no custom title is set. + */ + private function get_current_title() { + if ( is_singular() ) { + return S1_SEO_Meta_Post::get_title( get_the_ID() ); + } + + if ( is_category() || is_tag() || is_tax() ) { + $term = get_queried_object(); + if ( $term instanceof WP_Term ) { + return S1_SEO_Meta_Term::get_title( $term->term_id ); + } + } + + return ''; + } + + /** + * Return the SEO description for whatever is currently being displayed. + * + * @return string Empty string when no custom description is set. + */ + private function get_current_description() { + if ( is_singular() ) { + return S1_SEO_Meta_Post::get_description( get_the_ID() ); + } + + if ( is_category() || is_tag() || is_tax() ) { + $term = get_queried_object(); + if ( $term instanceof WP_Term ) { + return S1_SEO_Meta_Term::get_description( $term->term_id ); + } + } + + return ''; + } +} diff --git a/s1-seo-meta74/includes/class-s1-seo-meta-post.php b/s1-seo-meta74/includes/class-s1-seo-meta-post.php new file mode 100644 index 0000000..9bc8420 --- /dev/null +++ b/s1-seo-meta74/includes/class-s1-seo-meta-post.php @@ -0,0 +1,164 @@ +<?php +/** + * Handles saving and retrieving SEO meta for posts, pages and custom post types. + * + * @package S1_SEO_Meta + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +class S1_SEO_Meta_Post { + + /** + * Meta key for SEO title. + */ + const META_TITLE = '_s1_seo_title'; + + /** + * Meta key for SEO description. + */ + const META_DESC = '_s1_seo_description'; + + public function __construct() { + add_action( 'add_meta_boxes', array( $this, 'add_meta_box' ) ); + add_action( 'save_post', array( $this, 'save_meta' ), 10, 2 ); + } + + /** + * Register the meta box for all public post types. + */ + public function add_meta_box() { + $post_types = get_post_types( array( 'public' => true ), 'names' ); + + foreach ( $post_types as $post_type ) { + add_meta_box( + 's1-seo-meta-box', + __( 'S1 SEO: Title & Description', 's1-seo-meta' ), + array( $this, 'render_meta_box' ), + $post_type, + 'normal', + 'high' + ); + } + } + + /** + * Render the meta box HTML. + * + * @param WP_Post $post Current post object. + */ + public function render_meta_box( $post ) { + wp_nonce_field( 's1_seo_meta_save', 's1_seo_meta_nonce' ); + + $seo_title = get_post_meta( $post->ID, self::META_TITLE, true ); + $seo_desc = get_post_meta( $post->ID, self::META_DESC, true ); + ?> + <div class="s1-seo-wrap"> + <div class="s1-seo-field"> + <label for="s1_seo_title"> + <?php esc_html_e( 'SEO Title', 's1-seo-meta' ); ?> + </label> + <input + type="text" + id="s1_seo_title" + name="s1_seo_title" + value="<?php echo esc_attr( $seo_title ); ?>" + placeholder="<?php echo esc_attr( get_the_title( $post->ID ) ); ?>" + maxlength="666" + /> + <div class="s1-seo-counter"> + <span class="s1-seo-count" id="s1_seo_title_count">0</span>/60 + <?php esc_html_e( 'characters (recommended: 50–60)', 's1-seo-meta' ); ?> + </div> + </div> + + <div class="s1-seo-field"> + <label for="s1_seo_description"> + <?php esc_html_e( 'SEO Description', 's1-seo-meta' ); ?> + </label> + <textarea + id="s1_seo_description" + name="s1_seo_description" + rows="3" + maxlength="666" + placeholder="<?php esc_attr_e( 'Write a short description of this page…', 's1-seo-meta' ); ?>" + ><?php echo esc_textarea( $seo_desc ); ?></textarea> + <div class="s1-seo-counter"> + <span class="s1-seo-count" id="s1_seo_desc_count">0</span>/160 + <?php esc_html_e( 'characters (recommended: 120–160)', 's1-seo-meta' ); ?> + </div> + </div> + </div> + <?php + } + + /** + * Save SEO meta fields when the post is saved. + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + */ + public function save_meta( $post_id, $post ) { + // Verify nonce. + if ( + ! isset( $_POST['s1_seo_meta_nonce'] ) || + ! wp_verify_nonce( sanitize_key( $_POST['s1_seo_meta_nonce'] ), 's1_seo_meta_save' ) + ) { + return; + } + + // Skip autosaves and revisions. + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + return; + } + if ( wp_is_post_revision( $post_id ) ) { + return; + } + + // Check permissions. + $post_type_obj = get_post_type_object( $post->post_type ); + if ( ! current_user_can( $post_type_obj->cap->edit_post, $post_id ) ) { + return; + } + + // Save title. + if ( isset( $_POST['s1_seo_title'] ) ) { + update_post_meta( + $post_id, + self::META_TITLE, + sanitize_text_field( wp_unslash( $_POST['s1_seo_title'] ) ) + ); + } + + // Save description. + if ( isset( $_POST['s1_seo_description'] ) ) { + update_post_meta( + $post_id, + self::META_DESC, + sanitize_textarea_field( wp_unslash( $_POST['s1_seo_description'] ) ) + ); + } + } + + /** + * Static helper: retrieve SEO title for a given post. + * + * @param int $post_id Post ID. + * @return string + */ + public static function get_title( $post_id ) { + return (string) get_post_meta( $post_id, self::META_TITLE, true ); + } + + /** + * Static helper: retrieve SEO description for a given post. + * + * @param int $post_id Post ID. + * @return string + */ + public static function get_description( $post_id ) { + return (string) get_post_meta( $post_id, self::META_DESC, true ); + } +} diff --git a/s1-seo-meta74/includes/class-s1-seo-meta-term.php b/s1-seo-meta74/includes/class-s1-seo-meta-term.php new file mode 100644 index 0000000..e557cb9 --- /dev/null +++ b/s1-seo-meta74/includes/class-s1-seo-meta-term.php @@ -0,0 +1,236 @@ +<?php +/** + * Handles saving and retrieving SEO meta for taxonomy terms + * (categories, tags, and all public custom taxonomies). + * + * @package S1_SEO_Meta + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +class S1_SEO_Meta_Term { + + /** + * Option-table key prefix used to store term meta (WP < 4.4 fallback aware). + * From WP 4.4+ term meta is stored in wp_termmeta via get/update_term_meta(). + */ + const META_TITLE = 's1_seo_title'; + const META_DESC = 's1_seo_description'; + + public function __construct() { + add_action( 'init', array( $this, 'register_term_hooks' ) ); + } + + /** + * Register add/edit/save hooks for every public taxonomy. + */ + public function register_term_hooks() { + $taxonomies = get_taxonomies( array( 'public' => true ), 'names' ); + + foreach ( $taxonomies as $taxonomy ) { + // Fields on the "Add new term" form. + add_action( "{$taxonomy}_add_form_fields", array( $this, 'add_form_fields' ) ); + // Fields on the "Edit term" form. + add_action( "{$taxonomy}_edit_form_fields", array( $this, 'edit_form_fields' ), 10, 2 ); + // Save on create. + add_action( "created_{$taxonomy}", array( $this, 'save_term_meta' ), 10, 2 ); + // Save on update. + add_action( "edited_{$taxonomy}", array( $this, 'save_term_meta' ), 10, 2 ); + } + } + + /** + * Render fields on the "Add new term" screen. + * + * @param string $taxonomy Current taxonomy slug. + */ + public function add_form_fields( $taxonomy ) { + wp_nonce_field( 's1_seo_term_save', 's1_seo_term_nonce' ); + ?> + <div class="form-field s1-seo-term-wrap"> + <h3 class="s1-seo-term-heading"><?php esc_html_e( 'S1 SEO', 's1-seo-meta' ); ?></h3> + + <div class="form-field"> + <label for="s1_seo_title"><?php esc_html_e( 'SEO Title', 's1-seo-meta' ); ?></label> + <input + type="text" + id="s1_seo_title" + name="s1_seo_title" + value="" + maxlength="666" + /> + <p class="s1-seo-counter"> + <span id="s1_seo_title_count">0</span>/60 <?php esc_html_e( 'characters', 's1-seo-meta' ); ?> + </p> + </div> + + <div class="form-field"> + <label for="s1_seo_description"><?php esc_html_e( 'SEO Description', 's1-seo-meta' ); ?></label> + <?php + $settings = array( + 'media_buttons' => false, + 'textarea_rows' => 5, + 'teeny' => true, + 'tinymce' => array( + 'toolbar1' => 'bold,italic,underline,link,unlink', + 'toolbar2' => '', + ), + ); + wp_editor( '', 's1_seo_description', $settings ); + ?> + <p class="s1-seo-counter"> + <span id="s1_seo_desc_count">0</span>/160 <?php esc_html_e( 'characters (recommended: 120–160)', 's1-seo-meta' ); ?> + </p> + </div> + </div> + <?php + } + + /** + * Render fields on the "Edit term" screen. + * + * @param WP_Term $term Current term object. + * @param string $taxonomy Current taxonomy slug. + */ + public function edit_form_fields( $term, $taxonomy ) { + wp_nonce_field( 's1_seo_term_save', 's1_seo_term_nonce' ); + + $seo_title = get_term_meta( $term->term_id, self::META_TITLE, true ); + $seo_desc = get_term_meta( $term->term_id, self::META_DESC, true ); + ?> + <tr class="form-field s1-seo-term-row"> + <td colspan="2"> + <h3 class="s1-seo-term-heading"><?php esc_html_e( 'S1 SEO', 's1-seo-meta' ); ?></h3> + </td> + </tr> + + <tr class="form-field"> + <th scope="row"> + <label for="s1_seo_title"><?php esc_html_e( 'SEO Title', 's1-seo-meta' ); ?></label> + </th> + <td> + <input + type="text" + id="s1_seo_title" + name="s1_seo_title" + value="<?php echo esc_attr( $seo_title ); ?>" + placeholder="<?php echo esc_attr( $term->name ); ?>" + maxlength="60" + /> + <p class="description s1-seo-counter"> + <span id="s1_seo_title_count"><?php echo esc_html( mb_strlen( $seo_title ) ); ?></span>/60 + <?php esc_html_e( 'characters (recommended: 50–60)', 's1-seo-meta' ); ?> + </p> + </td> + </tr> + + <tr class="form-field"> + <th scope="row"> + <label for="s1_seo_description"><?php esc_html_e( 'SEO Description', 's1-seo-meta' ); ?></label> + </th> + <td> + <?php + $settings = array( + 'media_buttons' => false, + 'textarea_rows' => 5, + 'teeny' => true, + 'tinymce' => array( + 'toolbar1' => 'bold,italic,underline,link,unlink', + 'toolbar2' => '', + ), + ); + wp_editor( $seo_desc, 's1_seo_description', $settings ); + ?> + <p class="description s1-seo-counter"> + <span id="s1_seo_desc_count"><?php echo esc_html( mb_strlen( wp_strip_all_tags( $seo_desc ) ) ); ?></span>/160 + <?php esc_html_e( 'characters (recommended: 120–160)', 's1-seo-meta' ); ?> + </p> + </td> + </tr> + <?php + } + + /** + * Save term SEO meta. + * + * @param int $term_id Term ID. + */ + public function save_term_meta( $term_id ) { + if ( + ! isset( $_POST['s1_seo_term_nonce'] ) || + ! wp_verify_nonce( sanitize_key( $_POST['s1_seo_term_nonce'] ), 's1_seo_term_save' ) + ) { + return; + } + + if ( ! current_user_can( 'manage_categories' ) ) { + return; + } + + if ( isset( $_POST['s1_seo_title'] ) ) { + update_term_meta( + $term_id, + self::META_TITLE, + sanitize_text_field( wp_unslash( $_POST['s1_seo_title'] ) ) + ); + } + + if ( isset( $_POST['s1_seo_description'] ) ) { + $description = wp_unslash( $_POST['s1_seo_description'] ); + + // Разрешаем безопасные HTML теги для SEO описания + $allowed_tags = array( + 'a' => array( + 'href' => array(), + 'title' => array(), + 'target' => array(), + ), + 'b' => array(), + 'strong' => array(), + 'i' => array(), + 'em' => array(), + 'br' => array(), + 'p' => array(), + 'span' => array(), + 'ul' => array(), + 'ol' => array(), + 'li' => array(), + ); + + // Для администраторов разрешаем все теги, для остальных - только безопасные + if ( current_user_can( 'unfiltered_html' ) ) { + $sanitized = $description; + } else { + $sanitized = wp_kses( $description, $allowed_tags ); + } + + update_term_meta( + $term_id, + self::META_DESC, + $sanitized + ); + } + } + + /** + * Static helper: retrieve SEO title for a term. + * + * @param int $term_id Term ID. + * @return string + */ + public static function get_title( $term_id ) { + return (string) get_term_meta( $term_id, self::META_TITLE, true ); + } + + /** + * Static helper: retrieve SEO description for a term. + * + * @param int $term_id Term ID. + * @return string + */ + public static function get_description( $term_id ) { + return (string) get_term_meta( $term_id, self::META_DESC, true ); + } +} diff --git a/s1-seo-meta74/languages/s1-seo-meta.pot b/s1-seo-meta74/languages/s1-seo-meta.pot new file mode 100644 index 0000000..edc23d6 --- /dev/null +++ b/s1-seo-meta74/languages/s1-seo-meta.pot @@ -0,0 +1,65 @@ +# Copyright (C) 2024 S1 +# This file is distributed under the same license as the S1 SEO Meta plugin. +msgid "" +msgstr "" +"Project-Id-Version: S1 SEO Meta 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-01-01 00:00:00+00:00\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"X-Generator: WP-CLI\n" +"X-Domain: s1-seo-meta\n" + +#. Plugin Name of the plugin +msgid "S1 SEO Meta" +msgstr "" + +#. Plugin URI of the plugin +msgid "https://github.com/S1/s1-seo-meta" +msgstr "" + +#. Description of the plugin +msgid "Добавляет мета-теги Title и Description для страниц, публикаций, рубрик, категорий и других типов записей." +msgstr "" + +#. Author of the plugin +msgid "S1" +msgstr "" + +#: includes/class-s1-seo-meta-post.php +msgid "S1 SEO: Title & Description" +msgstr "" + +#: includes/class-s1-seo-meta-post.php +#: includes/class-s1-seo-meta-term.php +msgid "SEO Title" +msgstr "" + +#: includes/class-s1-seo-meta-post.php +#: includes/class-s1-seo-meta-term.php +msgid "SEO Description" +msgstr "" + +#: includes/class-s1-seo-meta-post.php +msgid "characters (recommended: 50–60)" +msgstr "" + +#: includes/class-s1-seo-meta-post.php +msgid "characters (recommended: 120–160)" +msgstr "" + +#: includes/class-s1-seo-meta-post.php +msgid "Write a short description of this page…" +msgstr "" + +#: includes/class-s1-seo-meta-term.php +msgid "S1 SEO" +msgstr "" + +#: includes/class-s1-seo-meta-term.php +msgid "characters" +msgstr "" diff --git a/s1-seo-meta74/readme.txt b/s1-seo-meta74/readme.txt new file mode 100644 index 0000000..dbabd91 --- /dev/null +++ b/s1-seo-meta74/readme.txt @@ -0,0 +1,48 @@ +=== S1 SEO Meta === +Contributors: s1 +Tags: seo, meta, title, description, meta tags +Requires at least: 5.8 +Tested up to: 6.5 +Requires PHP: 7.4 +Stable tag: 1.0.0 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html + +Adds custom SEO Title and Description meta fields to posts, pages, custom post types, categories, tags, and all public taxonomies. + +== Description == + +S1 SEO Meta adds a lightweight, no-bloat SEO meta box to every post/page editor and taxonomy term screen in the WordPress admin. Simply fill in your desired **SEO Title** and **SEO Description** and the plugin will: + +* Output `<meta name="description">` in the `<head>`. +* Override the page `<title>` tag (supports both classic `wp_title` and modern `document_title_parts` hooks). +* Show a live character counter with colour-coded feedback (green / amber / red). + +Works with posts, pages, custom post types, categories, tags, and all registered public taxonomies. + +== Installation == + +1. Upload the `s1-seo-meta` folder to `/wp-content/plugins/`. +2. Activate the plugin from the **Plugins** menu in WordPress admin. +3. Open any post, page, or term — the **S1 SEO: Title & Description** box will appear. + +== Frequently Asked Questions == + += Will this conflict with Yoast SEO or Rank Math? = +It may conflict if both plugins try to output a `<meta name="description">`. It is recommended to use only one SEO plugin at a time. + += Does it support custom post types? = +Yes. The meta box is added automatically to every post type registered with `public => true`. + += Does it support custom taxonomies? = +Yes. Fields appear on every taxonomy registered with `public => true`. + +== Changelog == + += 1.0.0 = +* Initial release. + +== Upgrade Notice == + += 1.0.0 = +First release. diff --git a/s1-seo-meta74/s1-seo-meta.php b/s1-seo-meta74/s1-seo-meta.php new file mode 100644 index 0000000..ebaf56a --- /dev/null +++ b/s1-seo-meta74/s1-seo-meta.php @@ -0,0 +1,56 @@ +<?php +/** + * Plugin Name: S1 SEO Meta + * Plugin URI: https://1studi.ru + * Description: Добавляет мета-теги Title и Description для страниц, публикаций, рубрик, категорий и других типов записей. С визуальным редактором для описания категорий. + * Version: 1.1.0 + * Requires at least: 5.8 + * Requires PHP: 7.3 + * Author: S1 + * License: GPL v2 or later + * License URI: https://www.gnu.org/licenses/gpl-2.0.html + * Text Domain: s1-seo-meta + * Domain Path: /languages + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +define( 'S1_SEO_META_VERSION', '1.1.0' ); +define( 'S1_SEO_META_FILE', __FILE__ ); +define( 'S1_SEO_META_DIR', plugin_dir_path( __FILE__ ) ); +define( 'S1_SEO_META_URL', plugin_dir_url( __FILE__ ) ); + +/** + * Load plugin text domain for translations. + */ +function s1_seo_meta_load_textdomain() { + load_plugin_textdomain( 's1-seo-meta', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); +} +add_action( 'plugins_loaded', 's1_seo_meta_load_textdomain' ); + +/** + * Load required files. + */ +require_once S1_SEO_META_DIR . 'includes/class-s1-seo-meta-post.php'; +require_once S1_SEO_META_DIR . 'includes/class-s1-seo-meta-term.php'; +require_once S1_SEO_META_DIR . 'includes/class-s1-seo-meta-output.php'; + +if ( is_admin() ) { + require_once S1_SEO_META_DIR . 'admin/class-s1-seo-meta-admin.php'; +} + +/** + * Initialise all plugin components. + */ +function s1_seo_meta_init() { + new S1_SEO_Meta_Post(); + new S1_SEO_Meta_Term(); + new S1_SEO_Meta_Output(); + + if ( is_admin() ) { + new S1_SEO_Meta_Admin(); + } +} +add_action( 'init', 's1_seo_meta_init' ); diff --git a/s1-seo-meta74/s1-seo-meta73.zip b/s1-seo-meta74/s1-seo-meta73.zip new file mode 100644 index 0000000..e6560c2 Binary files /dev/null and b/s1-seo-meta74/s1-seo-meta73.zip differ diff --git a/s1-show-category-description/s1-show-category-description.php b/s1-show-category-description/s1-show-category-description.php new file mode 100644 index 0000000..8353b83 --- /dev/null +++ b/s1-show-category-description/s1-show-category-description.php @@ -0,0 +1,102 @@ +<?php +/* +Plugin Name: S1 show category description +Plugin URI: https://1studi.ru +Description: Выводит описание категории/рубрики на странице архива (по умолчанию WP его скрывает) +Version: 1.1.0 +Author: S1 +Author URI: https://1studi.ru +License: GPL-2.0+ +Text Domain: s1-custom-scroll-to-top +Domain Path: /languages +*/ + +if (!defined('ABSPATH')) { + exit; +} + + +add_action('wp_head', function() { + // Можно добавить стили прямо в head (или подключить отдельный css) + // Раскомментируйте, если нужно + /* + echo '<style> + .category-description { + margin: 1.5em 0 2.5em; + padding: 1.2em 1.8em; + background: #f8f9fa; + border-left: 4px solid #0073aa; + border-radius: 4px; + line-height: 1.6; + } + .category-description p:last-child { + margin-bottom: 0; + } + </style>'; + */ +}, 20); + + +/** + * Основной вывод описания + */ +function scd_show_category_description() { + // Показываем только на страницах архивов категорий (можно расширить) + if (!is_category() && !is_tag() && !is_tax()) { + return; + } + + // Получаем описание + $description = get_the_archive_description(); + + if (empty($description)) { + return; + } + + // Можно обернуть в свой класс или изменить тег + $output = '<div class="category-description entry-content">'; + $output .= $description; + $output .= '</div>'; + + echo $output; +} + + +/** + * Вариант А — после заголовка архива (самый популярный) + */ +add_action('woocommerce_archive_description', 'scd_show_category_description', 15); // для woocommerce +add_action('generate_archive_title', 'scd_show_category_description', 20); // GeneratePress +add_action('astra_archive_header', 'scd_show_category_description', 15); // Astra + +// Универсальный способ — если тема не использует специальные хуки +add_action('wp_head', function() { + if (is_category() || is_tag() || is_tax()) { + add_filter('the_content', 'scd_inject_description_to_content', 9); + } +}); + +function scd_inject_description_to_content($content) { + // Показываем только в начале основного контента архива + if (!is_main_query() || !in_the_loop()) { + return $content; + } + + remove_filter('the_content', 'scd_inject_description_to_content', 9); + + $description = get_the_archive_description(); + if (empty($description)) { + return $content; + } + + return '<div class="category-description">' . $description . '</div>' . $content; +} + + +/** + * Вариант Б — короткий код [category_description] + * Можно вставить в любой блок / виджет / шаблон + */ +add_shortcode('category_description', function() { + return get_the_archive_description(); +}); \ No newline at end of file diff --git a/s1-show-category-description/s1-show-category-description.zip b/s1-show-category-description/s1-show-category-description.zip new file mode 100644 index 0000000..093f120 Binary files /dev/null and b/s1-show-category-description/s1-show-category-description.zip differ diff --git a/s1-smart-sticky-anything/s1-smart-sticky-anything.php b/s1-smart-sticky-anything/s1-smart-sticky-anything.php new file mode 100644 index 0000000..20b4a06 --- /dev/null +++ b/s1-smart-sticky-anything/s1-smart-sticky-anything.php @@ -0,0 +1,137 @@ +<?php +/* +Plugin Name: Smart Sticky Header with Scroll Interval +Description: Sticky header с интервалом скролла и клонированием (оригинал остаётся на месте) +Version: 1.2.0 +*/ + +add_action('admin_menu', function() { + add_options_page( + 'Sticky Header Settings', + 'Sticky Header', + 'manage_options', + 'smart-sticky-header', + 'ssh_settings_page' + ); +}); + +function ssh_settings_page() { + if (isset($_POST['save_settings'])) { + update_option('ssh_sticky_selector', sanitize_text_field($_POST['sticky_selector'])); + update_option('ssh_start_scroll', intval($_POST['start_scroll'])); + update_option('ssh_end_scroll', intval($_POST['end_scroll'])); + echo '<div class="updated"><p>Настройки сохранены!</p></div>'; + } + + $selector = get_option('ssh_sticky_selector', '#masthead, .site-header, header'); + $start_scroll = get_option('ssh_start_scroll', 100); + $end_scroll = get_option('ssh_end_scroll', 0); + ?> + <div class="wrap"> + <h1>Настройки Sticky Header</h1> + <form method="post"> + <table class="form-table"> + <tr> + <th><label>CSS-селектор хедера/меню</label></th> + <td> + <input type="text" name="sticky_selector" value="<?php echo esc_attr($selector) ?>" style="width:400px;" placeholder="#masthead, .site-header, header"/> + <p class="description">Можно несколько через запятую. Используй инспектор браузера для точного селектора.</p> + </td> + </tr> + <tr> + <th><label>Минимальный скролл (px)</label></th> + <td> + <input type="number" name="start_scroll" value="<?php echo esc_attr($start_scroll) ?>" min="0" style="width:100px;"/> + <p class="description">Sticky появляется после этого значения (default: 100).</p> + </td> + </tr> + <tr> + <th><label>Максимальный скролл (px)</label></th> + <td> + <input type="number" name="end_scroll" value="<?php echo esc_attr($end_scroll) ?>" min="0" style="width:100px;"/> + <p class="description">Sticky скрывается после этого значения. 0 — без лимита (default: 0).</p> + </td> + </tr> + </table> + <?php submit_button('Сохранить настройки', 'primary', 'save_settings'); ?> + </form> + </div> + <?php +} + +add_action('wp_enqueue_scripts', function() { + if (is_admin()) return; + + $selector = get_option('ssh_sticky_selector', '#masthead, .site-header, header'); + $start_scroll = get_option('ssh_start_scroll', 100); + $end_scroll = get_option('ssh_end_scroll', 0); + + // Выводим CSS и JS inline для простоты (можно вынести в файлы) + ?> + <style id="ssh-sticky-css"> + .ssh-sticky-clone { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + width: 100% !important; + z-index: 9998 !important; + background: #ffffff; + box-shadow: 0 3px 8px rgba(0,0,0,0.12); + opacity: 0; + transition: opacity 0.3s ease, transform 0.3s ease; + transform: translateY(-100%); + display: none; /* Initially hidden */ + } + .ssh-sticky-clone.ssh-visible { + opacity: 1; + transform: translateY(0); + display: block !important; + } + body.admin-bar .ssh-sticky-clone { + top: 32px !important; + } + @media (max-width: 782px) { + body.admin-bar .ssh-sticky-clone { + top: 46px !important; + } + } + </style> + + <script id="ssh-sticky-js"> + document.addEventListener('DOMContentLoaded', function() { + const selectors = '<?php echo esc_js($selector); ?>'.split(',').map(s => s.trim()); + const startScroll = <?php echo intval($start_scroll); ?>; + const endScroll = <?php echo intval($end_scroll); ?>; + + let originalHeader = null; + for (let sel of selectors) { + originalHeader = document.querySelector(sel); + if (originalHeader) break; + } + + if (!originalHeader) return; // Не нашли хедер + + // Клонируем + const clone = originalHeader.cloneNode(true); + clone.classList.add('ssh-sticky-clone'); + document.body.appendChild(clone); + + // Функция проверки скролла + function checkScroll() { + const scrollY = window.scrollY; + if (scrollY >= startScroll && (endScroll === 0 || scrollY <= endScroll)) { + clone.classList.add('ssh-visible'); + } else { + clone.classList.remove('ssh-visible'); + } + } + + // Слушатели + window.addEventListener('scroll', checkScroll); + window.addEventListener('resize', checkScroll); // На всякий случай + checkScroll(); // Инициализация + }); + </script> + <?php +}, 999); // Высокий приоритет, чтобы после других скриптов \ No newline at end of file diff --git a/s1-smart-sticky-anything/s1-smart-sticky-header.php.zip b/s1-smart-sticky-anything/s1-smart-sticky-header.php.zip new file mode 100644 index 0000000..da16b11 Binary files /dev/null and b/s1-smart-sticky-anything/s1-smart-sticky-header.php.zip differ