diff --git a/assets/js/src/document-library-grid.js b/assets/js/src/document-library-grid.js new file mode 100644 index 0000000..b52d9ec --- /dev/null +++ b/assets/js/src/document-library-grid.js @@ -0,0 +1,201 @@ +/** + * Document Library Lite – grid layout. + */ +(function ($) { + $(document).ready(function () { + const params = window.document_library_grid_params || {}; + + /** + * Tiny debounce helper. + */ + function debounce(fn, wait) { + let timer; + return function () { + const context = this; + const args = arguments; + clearTimeout(timer); + timer = setTimeout(function () { + fn.apply(context, args); + }, wait); + }; + } + + /** + * Build the request params for a container, honouring the active filter. + * + * The active category filter and the free-text search are mutually exclusive: when a category is + * active (set by clicking a category link), the search box only displays its name and is not sent + * as a keyword. + * + * @param {jQuery} $container + * @param {number} page + * @return {Object} + */ + function requestParams($container, page) { + const category = $container.attr("data-active-category") || ""; + const search = category + ? "" + : $container.find(".dlp-grid-search-input").val() || ""; + const length = $container.find(".dlp-grid-length-select").val() || ""; + + return { + category: category, + search_query: search, + length: length, + page_number: page, + }; + } + + /** + * Load a page of grid cards over AJAX and swap the relevant parts into the container. + * + * @param {jQuery} $container The .dlp-grid-container element. + * @param {number} page The 1-based page number to load. + */ + function loadPage($container, page) { + if (!params.ajax_url) { + return; + } + + $container.addClass("dlp-grid-loading"); + + $.ajax({ + url: params.ajax_url, + type: "POST", + data: $.extend( + { + action: params.ajax_action, + grid_id: $container.attr("id"), + _ajax_nonce: params.ajax_nonce, + }, + requestParams($container, page) + ), + }) + .done(function (response) { + if (!response) { + return; + } + + if (response.grid) { + $container + .find(".dlp-grid-documents") + .replaceWith(response.grid); + } + + if (response.totals) { + $container.find(".dlp-grid-totals").replaceWith(response.totals); + } + + if (response.pagination) { + $container + .find(".dlp-grid-pagination") + .replaceWith(response.pagination); + } + + // Scroll back to the top of the grid on page change. + const scrollOffset = $container.data("scroll-offset"); + if (scrollOffset !== false && typeof scrollOffset !== "undefined") { + const adminBar = $("#wpadminbar"); + let top = + $container.offset().top - (parseInt(scrollOffset, 10) || 0); + if (adminBar.length) { + top -= adminBar.outerHeight() || 32; + } + $("html, body").animate({ scrollTop: top }, 300); + } + + $container.trigger("grid-loaded"); + }) + .always(function () { + $container.removeClass("dlp-grid-loading"); + }); + } + + // Pagination clicks. + $(document).on("click", ".dlp-grid-paginate-button", function () { + const $button = $(this); + + if ($button.hasClass("disabled") || $button.hasClass("current")) { + return; + } + + const $container = $button.closest(".dlp-grid-container"); + loadPage($container, $button.data("page-number")); + }); + + // Free-text search (debounced). Typing clears any active category filter. + $(document).on( + "input", + ".dlp-grid-search-input", + debounce(function () { + const $container = $(this).closest(".dlp-grid-container"); + $container.attr("data-active-category", ""); + loadPage($container, 1); + }, 300) + ); + + // Page-length change → reload from page 1 with the new documents-per-page. + $(document).on("change", ".dlp-grid-length-select", function () { + const $container = $(this).closest(".dlp-grid-container"); + loadPage($container, 1); + }); + + // Category link click → filter the grid by that category (only when search_on_click is enabled, in + // which case the link carries the .dlp-grid-category-link class). The search box is left untouched. + $(document).on("click", ".dlp-grid-category-link", function (event) { + event.preventDefault(); + + const $link = $(this); + const $container = $link.closest(".dlp-grid-container"); + + $container.attr("data-active-category", $link.data("slug")); + loadPage($container, 1); + }); + + // Lightbox (PhotoSwipe) on the featured image. + $(document).on( + "click", + ".dlp-grid-card-featured-img a.dlw-lightbox", + function (event) { + event.preventDefault(); + event.stopPropagation(); + + const pswpElement = $(".pswp")[0]; + const $img = $(this).find("img"); + + if (!pswpElement || $img.length < 1) { + return; + } + + const items = [ + { + src: $img.attr("data-large_image"), + w: $img.attr("data-large_image_width"), + h: $img.attr("data-large_image_height"), + title: + $img.attr("data-caption") && $img.attr("data-caption").length + ? $img.attr("data-caption") + : $img.attr("title"), + }, + ]; + + const photoswipe = new PhotoSwipe( + pswpElement, + PhotoSwipeUI_Default, + items, + { + index: 0, + shareEl: false, + closeOnScroll: false, + history: false, + hideAnimationDuration: 0, + showAnimationDuration: 0, + } + ); + photoswipe.init(); + + return false; + } + ); + }); +})(jQuery); diff --git a/assets/scss/document-library-grid.scss b/assets/scss/document-library-grid.scss new file mode 100644 index 0000000..b8953b1 --- /dev/null +++ b/assets/scss/document-library-grid.scss @@ -0,0 +1,284 @@ +/** + * Document Library Lite – grid layout styles. + */ + +.dlp-grid-container { + margin-top: 1.2em; + margin-bottom: 2.2em; + + &.dlp-grid-loading { + opacity: 0.6; + pointer-events: none; + } + + .dlp-grid-header { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: space-between; + align-items: start; + margin-bottom: 1rem; + color: #333333; + } + + .dlp-grid-length-select { + height: auto; + line-height: 1.25; + margin-bottom: 0; + padding: .2em .5em; + width: auto; + border: 1px solid #aaa; + border-radius: 3px; + background-color: transparent; + } + + .dlp-grid-search-input { + // Matches the table layout's .dataTables_filter input styling. + box-sizing: border-box; + border: 1px solid #aaa; + border-radius: 3px; + background-color: transparent; + margin: 0 0 0 3px; + padding: 0.3em 0.5em; + width: auto; + height: auto; + line-height: 1.25; + font-size: inherit; + } + + .dlp-grid-documents { + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(4, minmax(0, 1fr)); + + &.columns-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + + &.columns-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + &.columns-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + &.columns-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + &.columns-5 { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + &.columns-6 { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + } + + @media (max-width: 960px) { + .dlp-grid-documents { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + } + } + + @media (max-width: 600px) { + .dlp-grid-documents { + grid-template-columns: repeat(1, minmax(0, 1fr)) !important; + } + } +} + +.dlp-grid-empty { + grid-column: 1 / -1; + margin: 0; + padding: 1rem 0; +} + +.dlp-grid-card { + display: flex; + background: #fff; + border: 1px solid #e2e2e2; + border-radius: 6px; + overflow: hidden; + + .dlp-grid-card-inner { + display: flex; + flex-direction: column; + width: 100%; + } + + .dlp-grid-card-featured-img { + a { + display: block; + } + + img { + display: block; + width: 100%; + height: auto; + aspect-ratio: 4 / 3; + object-fit: cover; + } + } + + .dlp-grid-card-featured-icon { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + background: #f6f7f7; + + svg { + width: 48px; + height: 48px; + } + } + + .dlp-grid-card-content { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + } + + .dlp-grid-card-info { + display:flex; + align-items: center; + flex-wrap: nowrap; + justify-content: flex-start; + } + + .dlp-grid-card-categories { + margin-left: auto; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.03em; + opacity: 0.75; + + .dlp-grid-category-link { + cursor: pointer; + } + } + + .dlp-grid-card-title { + font-weight: 700; + } + + .dlp-grid-card-excerpt { + flex: 1; + font-size: 0.9rem; + } + + .dlp-grid-card-link { + margin-top: auto; + padding-top: 0.5rem; + } +} + +.dlp-grid-footer { + margin-top: 10px; + + // Clearfix for the floated info (left) and pagination (right). + &::after { + content: ""; + display: block; + clear: both; + } +} + +// Info text — "Showing 1 to 10 of 12 entries" (left). +.dlp-grid-totals { + clear: both; + float: left; + padding-top: 4px; + color: #333; +} + +// Pagination (right). Styling ported from DataTables so it matches the table layout exactly. +.dlp-grid-pagination { + float: right; + text-align: right; + color: #333; + + .dlp-grid-paginate-button { + box-sizing: border-box; + display: inline-block; + min-width: 1.5em; + padding: 0.3em 0.9em; + margin-left: 2px; + text-align: center; + text-decoration: none !important; + cursor: pointer; + color: #333 !important; + border: 1px solid transparent; + border-radius: 2px; + box-shadow: none; + + &:hover { + color: #fff !important; + border: 1px solid #111; + background: linear-gradient(to bottom, #585858 0%, #111 100%); + } + + &:active { + outline: none; + background: linear-gradient(to bottom, #2b2b2b 0%, #0c0c0c 100%); + box-shadow: inset 0 0 3px #111; + } + + &.current, + &.current:hover { + color: #333 !important; + border: 1px solid #979797; + background: linear-gradient(to bottom, #fff 0%, #dcdcdc 100%); + } + + &.disabled, + &.disabled:hover, + &.disabled:active { + cursor: default; + color: #666 !important; + border: 1px solid transparent; + background: transparent; + box-shadow: none; + } + } +} + +.document-library-button { + display: inline-flex !important; + align-items: center; + justify-content: center; + word-break: keep-all; + font-size: inherit; + min-height: 2em; + line-height: 1.9em; + padding: 0px 18px !important; + + &:hover { + text-decoration: none; + } + + &::after { + display: none; + } +} + +.dll-button-icon { + min-width: 18px; + min-height: 18px; + width: 18px; + height: 18px; + line-height: 1 !important; +} + +.dll-button-icon-text { + margin-right: 10px; +} + +.dll-file-icon { + width: 48px; + height: 48px; +} diff --git a/src/Admin/Settings_Tab/Display.php b/src/Admin/Settings_Tab/Display.php index 604411e..94c6697 100644 --- a/src/Admin/Settings_Tab/Display.php +++ b/src/Admin/Settings_Tab/Display.php @@ -54,8 +54,8 @@ public function register_settings() { // Table section Settings_API_Helper::add_settings_section( 'dlp_table', self::MENU_SLUG, __( 'Table', 'document-library-lite' ), '__return_false', $this->get_table_settings() ); - // Grid section - Pro Only - Settings_API_Helper::add_settings_section( 'dlp_grid', self::MENU_SLUG, __( 'Grid', 'document-library-lite' ), [ $this, 'display_grid_section' ], [] ); + // Grid section + Settings_API_Helper::add_settings_section( 'dlp_grid', self::MENU_SLUG, __( 'Grid', 'document-library-lite' ), [ $this, 'display_grid_section' ], $this->get_grid_settings() ); // Sorting section Settings_API_Helper::add_settings_section( 'dlp_sort_by', self::MENU_SLUG, __( 'Sorting', 'document-library-lite' ), '__return_false', $this->get_sort_by_settings() ); @@ -100,12 +100,39 @@ public function display_pro_only_section() { */ public function display_grid_section() { printf( - '

' . - esc_html__( 'Display your documents in a responsive grid layout. Control the grid content, clickable fields, columns, and customize the appearance to match your site design.', 'document-library-lite' ) . - '

' . - '

%s

', - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - Lib_Util::barn2_link( 'wordpress-plugins/document-library-pro/?utm_source=settings&utm_medium=settings&utm_campaign=settingsinline&utm_content=dlw-settings', __( 'Pro version only', 'document-library-lite' ), true ) + '

%s

', + esc_html__( 'Display your documents in a responsive grid layout. Set the default layout to Grid to enable it.', 'document-library-lite' ) + ); + } + + /** + * Get the Grid settings. + * + * @return array + */ + private function get_grid_settings() { + return Options::mark_readonly_settings( + [ + [ + 'id' => Options::SHORTCODE_OPTION_KEY . '[grid_content]', + 'title' => __( 'Grid content', 'document-library-lite' ), + 'type' => 'text', + 'desc' => __( 'Choose which information to display in the grid of documents.', 'document-library-lite' ) . ' ' . Lib_Util::barn2_link( 'kb/document-library-wordpress-documentation/#document-tables', '', true ), + 'default' => 'image,title,content,link', + ], + [ + 'id' => Options::SHORTCODE_OPTION_KEY . '[grid_columns]', + 'title' => __( 'Number of columns', 'document-library-lite' ), + 'type' => 'number', + 'class' => 'small-text', + 'desc' => __( 'The number of columns to display in the grid layout.', 'document-library-lite' ), + 'default' => 4, + 'custom_attributes' => [ + 'min' => 1, + 'max' => 6, + ], + ], + ] ); } @@ -162,13 +189,6 @@ private function get_table_settings() { 'desc' => __( 'Enter the fields to include in your document tables.', 'document-library-lite' ) . ' ' . Lib_Util::barn2_link( 'kb/document-library-wordpress-documentation/#document-tables', '', true ), 'default' => 'id,title,content,image,date,doc_categories,link', ], - [ - 'id' => Options::SHORTCODE_OPTION_KEY . '[image_size]', - 'title' => __( 'Image size', 'document-library-lite' ), - 'type' => 'text', - 'desc' => __( 'Enter WxH in pixels (e.g. 80x80).', 'document-library-lite' ) . ' ' . Lib_Util::barn2_link( 'kb/document-library-image-options/#image-size', '', true ), - 'default' => '70x70', - ], ] ); } diff --git a/src/Admin/Settings_Tab/General.php b/src/Admin/Settings_Tab/General.php index 3671ea6..aad512e 100644 --- a/src/Admin/Settings_Tab/General.php +++ b/src/Admin/Settings_Tab/General.php @@ -60,6 +60,9 @@ public function register_settings() { // Frontend Submission - Pro Only Settings_API_Helper::add_settings_section( 'dlp_frontend_submission', self::MENU_SLUG, __( 'Front end document submission', 'document-library-lite' ), [ $this, 'display_frontend_submission_section' ], [] ); + + // Lead Capture - Pro Only + Settings_API_Helper::add_settings_section( 'dlp_lead_capture', self::MENU_SLUG, __( 'Lead capture', 'document-library-lite' ), [ $this, 'display_lead_capture_section' ], [] ); } /** @@ -131,6 +134,22 @@ public function display_frontend_submission_section() { ); } + /** + * Output the Lead Capture section description. + */ + public function display_lead_capture_section() { + printf( + '

' . + esc_html__( 'Require users to enter their email address before they can access document links.', 'document-library-lite' ) . + ' %s

' . + '

%s

', + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + Lib_Util::barn2_link( 'kb/lead-capture/', __( 'Read more', 'document-library-lite' ), true ), + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + Lib_Util::barn2_link( 'wordpress-plugins/document-library-pro/?utm_source=settings&utm_medium=settings&utm_campaign=settingsinline&utm_content=dlw-settings', __( 'Pro version only', 'document-library-lite' ), true ) + ); + } + /** * Get the General settings. * diff --git a/src/Admin/Wizard/Steps/Layout.php b/src/Admin/Wizard/Steps/Layout.php index c6b1969..6736fcc 100644 --- a/src/Admin/Wizard/Steps/Layout.php +++ b/src/Admin/Wizard/Steps/Layout.php @@ -42,12 +42,6 @@ public function init() { public function setup_fields() { $fields = [ - 'columns' => [ - 'label' => __( 'Columns', 'document-library-lite' ), - 'description' => __( 'Enter the fields to include in your document tables.', 'document-library-lite' ) . ' ' . Lib_Util::barn2_link( 'kb/document-library-wordpress-documentation/#document-tables-tab', esc_html__( 'Read more', 'document-library-lite' ), true ), - 'type' => 'text', - 'value' => $this->values['columns'], - ], 'layout' => [ 'label' => __( 'Default layout', 'document-library-lite' ), 'type' => 'radio', @@ -59,10 +53,45 @@ public function setup_fields() { [ 'value' => 'grid', 'label' => __( 'Grid', 'document-library-lite' ), - ] + ], + ], + 'value' => $this->values['layout'], + ], + 'columns' => [ + 'label' => __( 'Columns', 'document-library-lite' ), + 'description' => __( 'Enter the fields to include in your document tables.', 'document-library-lite' ) . ' ' . Lib_Util::barn2_link( 'kb/document-library-wordpress-documentation/#document-tables', esc_html__( 'Read more', 'document-library-lite' ), true ), + 'type' => 'text', + 'value' => $this->values['columns'], + 'conditions' => [ + 'layout' => [ + 'op' => 'eq', + 'value' => 'table', + ], + ], + ], + 'grid_content' => [ + 'label' => __( 'Grid content', 'document-library-lite' ), + 'description' => __( 'Choose which information to display in the grid of documents.', 'document-library-lite' ) . ' ' . Lib_Util::barn2_link( 'kb/document-library-wordpress-documentation/#document-grids', '', true ), + 'type' => 'text', + 'value' => $this->values['grid_content'], + 'conditions' => [ + 'layout' => [ + 'op' => 'eq', + 'value' => 'grid', + ], + ], + ], + 'grid_columns' => [ + 'label' => __( 'Number of columns', 'document-library-lite' ), + 'type' => 'number', + 'description' => __( 'The number of columns to display in the grid layout.', 'document-library-lite' ), + 'value' => $this->values['grid_columns'], + 'conditions' => [ + 'layout' => [ + 'op' => 'eq', + 'value' => 'grid', + ], ], - 'value' => 'table', - 'premium' => true, ], 'folders' => [ 'title' => __( 'Folders', 'document-library-lite' ), @@ -72,9 +101,7 @@ public function setup_fields() { 'premium' => true, ], ]; - return $fields; - } /** @@ -82,11 +109,17 @@ public function setup_fields() { */ public function submit( array $values ) { - $columns = isset( $values['columns'] ) && ! empty( trim( $values['columns'] ) ) ? $values['columns'] : $this->values['columns']; + $layout = isset( $values['layout'] ) ? $values['layout'] : $this->values['layout']; + $columns = isset( $values['columns'] ) && ! empty( trim( $values['columns'] ) ) ? $values['columns'] : $this->values['columns']; + $grid_content = isset( $values['grid_content'] ) && ! empty( trim( $values['grid_content'] ) ) ? $values['grid_content'] : $this->values['grid_content']; + $grid_columns = isset( $values['grid_columns'] ) && is_numeric( $values['grid_columns'] ) ? $values['grid_columns'] : $this->values['grid_columns']; Options::update_shortcode_option( [ - 'columns' => $columns, + 'layout' => $layout, + 'columns' => $columns, + 'grid_content' => $grid_content, + 'grid_columns' => $grid_columns, ] ); diff --git a/src/Document_Library_Shortcode.php b/src/Document_Library_Shortcode.php index 805fd10..e8ed597 100644 --- a/src/Document_Library_Shortcode.php +++ b/src/Document_Library_Shortcode.php @@ -32,6 +32,13 @@ class Document_Library_Shortcode implements Registerable, Standard_Service { */ private static $script_params = []; + /** + * Tracks whether the global grid AJAX params have been localized. + * + * @var bool + */ + private static $grid_params_printed = false; + /** * {@inheritdoc} */ @@ -55,6 +62,11 @@ public function do_shortcode( $atts, $content = '' ) { // Store the configuration securely and get a unique ID $table_id = Config_Builder::store( $atts ); + // Render a grid instead of a table when the layout is set to 'grid'. + if ( isset( $atts['layout'] ) && $atts['layout'] === 'grid' ) { + return $this->do_grid_shortcode( $atts, $table_id ); + } + $table = new Simple_Document_Library( $atts, $table_id ); // Determine sort order - if not set or empty, use automatic based on sort_by @@ -102,6 +114,44 @@ public function do_shortcode( $atts, $content = '' ) { return ob_get_clean(); } + /** + * Handles rendering the document library as a grid. + * + * @param array $atts The parsed shortcode attributes. + * @param string $table_id The stored configuration ID. + * @return string The grid HTML output. + */ + private function do_grid_shortcode( $atts, $table_id ) { + $grid = new Simple_Document_Grid( $atts, $table_id ); + + if ( apply_filters( 'document_library_grid_load_scripts', true ) ) { + wp_enqueue_style( 'document-library-grid' ); + wp_enqueue_script( 'document-library-grid' ); + + // Localize the shared grid AJAX params once per page. + if ( ! self::$grid_params_printed ) { + wp_localize_script( + 'document-library-grid', + 'document_library_grid_params', + apply_filters( + 'document_library_grid_script_params', + [ + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'ajax_nonce' => wp_create_nonce( 'dll_load_grid' ), + 'ajax_action' => 'dll_load_grid', + ] + ) + ); + + self::$grid_params_printed = true; + } + } + + Frontend_Scripts::load_photoswipe_resources( $grid->args['lightbox'] ); + + return $grid->get_container(); + } + /** * Print script params in the footer before scripts are printed. */ diff --git a/src/Document_Query.php b/src/Document_Query.php new file mode 100644 index 0000000..f0c13c6 --- /dev/null +++ b/src/Document_Query.php @@ -0,0 +1,175 @@ + + * @license GPL-3.0 + * @copyright Barn2 Media Ltd + */ +trait Document_Query { + + /** + * Generate an inner array for the 'tax_query' arg in WP_Query. + * + * @param string $terms The list of terms as a string + * @param string $taxonomy The taxonomy name + * @param string $operator The SQL operator: IN, NOT IN, AND, etc + * @param string $field Add tax query by `term_id` or `slug`. Leave empty to auto-detect correct type + * @return array A tax query sub-array + */ + private function tax_query_item( $terms, $taxonomy, $operator = 'IN', $field = '' ) { + $and_relation = 'AND' === $operator; + + // comma-delimited list = OR, plus-delimited = AND + if ( ! is_array( $terms ) ) { + if ( false !== strpos( $terms, '+' ) ) { + $terms = explode( '+', $terms ); + $and_relation = true; + } else { + $terms = explode( ',', $terms ); + } + } + + // Do we have slugs or IDs? + if ( ! $field ) { + $using_term_ids = count( $terms ) === count( array_filter( $terms, 'is_numeric' ) ); + $field = $using_term_ids && ( ! isset( $this->args['numeric_terms'] ) || ! $this->args['numeric_terms'] ) ? 'term_id' : 'slug'; + } + + // Strange bug when using operator => 'AND' in individual tax queries - + // We need to separate these out into separate 'IN' arrays joined by and outer relation => 'AND' + if ( $and_relation && count( $terms ) > 1 ) { + $result = [ 'relation' => 'AND' ]; + + foreach ( $terms as $term ) { + $result[] = [ + 'taxonomy' => $taxonomy, + 'terms' => $term, + 'operator' => 'IN', + 'field' => $field, + ]; + } + + return $result; + } else { + return [ + 'taxonomy' => $taxonomy, + 'terms' => $terms, + 'operator' => $operator, + 'field' => $field, + ]; + } + } + + /** + * Run the posts query. + * + * @param array $query_args + * @return \WP_Post[] + */ + public function run_table_query( $query_args ) { + do_action( 'document_library_before_posts_query', $this ); + + $query = get_posts( $query_args ); + + do_action( 'document_library_after_posts_query', $this ); + + return $query; + } + + /** + * Build the posts query args, applying lazy-load / post-limit constraints. + * + * @param array $query_args + * @return array + */ + public function build_table_query( $query_args ) { + if ( $this->args['lazy_load'] ) { + // Ensure rows per page doesn't exceed post limit + $query_args['posts_per_page'] = $this->check_within_post_limit( $this->args['rows_per_page'] ); + $query_args['offset'] = $this->args['offset']; + } else { + $query_args['posts_per_page'] = $this->args['post_limit']; + } + return apply_filters( 'document_library_table_query_args', $query_args, $this ); + } + + /** + * Constrain a count to the configured post limit. + * + * @param int $count + * @return int + */ + private function check_within_post_limit( $count ) { + return is_int( $this->args['post_limit'] ) && $this->args['post_limit'] > 0 ? min( $this->args['post_limit'], $count ) : $count; + } + + /** + * Get the total number of posts matching the query. + * + * @return int + */ + public function get_total_posts() { + if ( is_numeric( $this->total_posts ) ) { + return $this->total_posts; + } + + $total = 0; + + $total_query = new \WP_Query( $this->build_post_totals_query( $this->post_args ) ); + $total = $total_query->post_count; + + $this->total_posts = $this->check_within_post_limit( $total ); + + return $this->total_posts; + } + + /** + * Build the query args used to count the total posts. + * + * @param array $args + * @return array + */ + private function build_post_totals_query( $args ) { + $query_args = $this->build_table_query( $args ); + $query_args['offset'] = 0; + $query_args['posts_per_page'] = -1; + $query_args['fields'] = 'ids'; + + return apply_filters( 'document_library_query_args', $query_args, $this ); + } + + /** + * Build the base post query args from the current settings. + */ + public function set_post_args() { + // Start building the args needed for our posts query + $this->post_args = [ + 'post_type' => Post_Type::POST_TYPE_SLUG, + // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page + 'posts_per_page' => apply_filters( 'document_library_table_post_limit', 1000 ), + 'post_status' => $this->args['status'], + 'order' => strtoupper( $this->args['sort_order'] ), + 'orderby' => $this->args['sort_by'], + 'suppress_filters' => false, // Ensure WPML filters run on this query + ]; + + // Add our doc_category if we have one. + if ( isset( $this->args['doc_category'] ) && strlen( $this->args['doc_category'] ) > 0 ) { + $this->post_args = array_merge( + $this->post_args, + [ 'tax_query' => [ $this->tax_query_item( $this->args['doc_category'], 'doc_categories' ) ] ] + ); + } + + if ( isset( $this->args['search_value'] ) && strlen( $this->args['search_value'] ) > 0 ) { + $this->post_args['s'] = $this->args['search_value']; + } + } +} diff --git a/src/Frontend_Scripts.php b/src/Frontend_Scripts.php index 464c3ab..daa1fe8 100644 --- a/src/Frontend_Scripts.php +++ b/src/Frontend_Scripts.php @@ -48,6 +48,7 @@ public function register_styles() { wp_register_style( 'jquery-datatables-dlw', plugins_url( 'assets/js/datatables/datatables.min.css', $this->plugin->get_file() ), [], self::DATATABLES_VERSION ); wp_register_style( 'document-library', plugins_url( 'assets/css/document-library-main.css', $this->plugin->get_file() ), [ 'jquery-datatables-dlw' ], $this->plugin->get_version() ); + wp_register_style( 'document-library-grid', plugins_url( 'assets/css/document-library-grid.css', $this->plugin->get_file() ), [], $this->plugin->get_version() ); wp_register_style( 'photoswipe', plugins_url( 'assets/js/photoswipe/photoswipe.min.css', $this->plugin->get_file() ), [], self::PHOTOSWIPE_VERSION ); wp_register_style( 'photoswipe-default-skin', plugins_url( 'assets/js/photoswipe/default-skin/default-skin.min.css', $this->plugin->get_file() ), [ 'photoswipe' ], self::PHOTOSWIPE_VERSION ); } @@ -60,6 +61,7 @@ public function register_scripts() { wp_register_script( 'jquery-datatables-dlw', plugins_url( "assets/js/datatables/datatables{$suffix}.js", $this->plugin->get_file() ), [ 'jquery' ], self::DATATABLES_VERSION, true ); wp_register_script( 'document-library', plugins_url( 'assets/js/document-library-main.js', $this->plugin->get_file() ), [ 'jquery', 'jquery-datatables-dlw' ], $this->plugin->get_version(), true ); + wp_register_script( 'document-library-grid', plugins_url( 'assets/js/document-library-grid.js', $this->plugin->get_file() ), [ 'jquery' ], $this->plugin->get_version(), true ); wp_register_script( 'photoswipe', plugins_url( 'assets/js/photoswipe/photoswipe.min.js', $this->plugin->get_file() ), [], self::PHOTOSWIPE_VERSION, true ); wp_register_script( 'photoswipe-ui-default', plugins_url( 'assets/js/photoswipe/photoswipe-ui-default.min.js', $this->plugin->get_file() ), [ 'photoswipe' ], self::PHOTOSWIPE_VERSION, true ); } diff --git a/src/Simple_Document_Grid.php b/src/Simple_Document_Grid.php new file mode 100644 index 0000000..7568fad --- /dev/null +++ b/src/Simple_Document_Grid.php @@ -0,0 +1,564 @@ + + * @license GPL-3.0 + * @copyright Barn2 Media Ltd + */ +class Simple_Document_Grid { + + use Document_Query; + + public $args = []; + public $post_args = []; + private $total_posts = null; + private $grid_id = null; + + /** + * The configured documents-per-page, captured before get_page_posts() mutates $args['rows_per_page']. + * + * @var int + */ + private $base_per_page = self::DEFAULT_PAGE_LENGTH; + + /** + * The fields that may be displayed on a grid card. 'id' and 'date' are intentionally excluded. + */ + const ALLOWED_FIELDS = [ 'title', 'content', 'image', 'doc_categories', 'link' ]; + const ALLOWED_FIELDS_DEFAULT = 'image,title,content,link'; + + /** + * The selectable page-length options, and the default when the configured value isn't one of them. + */ + const PAGE_LENGTHS = [ 10, 25, 50, 100 ]; + const DEFAULT_PAGE_LENGTH = 10; + + /** + * Constructor. + * + * @param array $args The shortcode/settings arguments. + * @param string $grid_id A unique ID for this grid (the stored config ID). + */ + public function __construct( $args, $grid_id = null ) { + $this->args = $this->validate_options( $args ); + $this->grid_id = $grid_id ? $grid_id : 'document-library-grid'; + + // Capture the configured page length now: get_page_posts() later overwrites $args['rows_per_page']. + $this->base_per_page = isset( $this->args['rows_per_page'] ) ? (int) $this->args['rows_per_page'] : self::DEFAULT_PAGE_LENGTH; + + $this->set_post_args(); + } + + /** + * Validate the incoming options. + * + * @param array $args + * @return array + */ + public function validate_options( $args ) { + $boolean_options = [ 'lightbox', 'link_icon', 'link_target', 'text_links_new_tab' ]; + + foreach ( $boolean_options as $option ) { + $args[ $option ] = isset( $args[ $option ] ) ? filter_var( $args[ $option ], FILTER_VALIDATE_BOOLEAN ) : false; + } + + $valid_post_statuses = [ 'publish', 'pending', 'draft', 'future', 'any' ]; + $args['status'] = isset( $args['status'] ) && in_array( $args['status'], $valid_post_statuses, true ) ? $args['status'] : 'publish'; + + $args['grid_columns'] = isset( $args['grid_columns'] ) ? max( 1, min( 6, (int) $args['grid_columns'] ) ) : 4; + + // Defaults to true (it is a general shortcode default) rather than the generic "missing => false". + $args['search_on_click'] = isset( $args['search_on_click'] ) ? filter_var( $args['search_on_click'], FILTER_VALIDATE_BOOLEAN ) : true; + + if ( empty( $args['grid_content'] ) ) { + $args['grid_content'] = self::ALLOWED_FIELDS_DEFAULT; + } + + return $args; + } + + /** + * Parse the grid_content setting into a validated list of fields to render on each card. + * + * Mirrors Simple_Document_Library::get_columns(): comma-separated, trimmed, lowercased, then + * intersected with the allowed grid fields. Falls back to the default set when empty/invalid. + * + * @return string[] + */ + public function get_grid_fields() { + $raw = isset( $this->args['grid_content'] ) ? $this->args['grid_content'] : self::ALLOWED_FIELDS_DEFAULT; + + $fields = array_filter( array_map( 'trim', explode( ',', strtolower( $raw ) ) ) ); + $fields = array_values( array_intersect( $fields, self::ALLOWED_FIELDS ) ); + + if ( empty( $fields ) ) { + $fields = explode( ',', self::ALLOWED_FIELDS_DEFAULT ); + } + + return $fields; + } + + /** + * Number of documents shown per grid page. + * + * @return int + */ + public function get_per_page() { + $lengths = $this->get_page_lengths(); + $requested = isset( $this->args['requested_length'] ) ? (int) $this->args['requested_length'] : $this->base_per_page; + + // A valid numeric selection always wins (lets a rows_per_page = -1 site still pick 10/25/50/100). + if ( $requested > 0 && in_array( $requested, $lengths, true ) ) { + return $requested; + } + + // "All" — show every document on one page (only when the site is configured with rows_per_page = -1; + // the AJAX handler never sets a negative requested_length). + if ( -1 === $requested || -1 === $this->base_per_page ) { + return -1; + } + + return ( $this->base_per_page > 0 && in_array( $this->base_per_page, $lengths, true ) ) + ? $this->base_per_page + : self::DEFAULT_PAGE_LENGTH; + } + + /** + * The selectable page-length options: the defaults plus the configured value when it isn't one of them, + * inserted in ascending order (e.g. rows_per_page=8 => [8,10,25,50,100]). + * + * @return int[] + */ + public function get_page_lengths() { + $lengths = self::PAGE_LENGTHS; + + if ( $this->base_per_page > 0 && ! in_array( $this->base_per_page, $lengths, true ) ) { + $lengths[] = $this->base_per_page; + sort( $lengths, SORT_NUMERIC ); + } + + return $lengths; + } + + /** + * Query the documents for a given page. + * + * @param int $page The 1-based page number. + * @return \WP_Post[] + */ + public function get_page_posts( $page = 1 ) { + $page = max( 1, (int) $page ); + $per_page = $this->get_per_page(); + + // Force offset-based paging for the grid regardless of the table lazy-load setting. + $this->args['lazy_load'] = true; + + if ( $per_page < 1 ) { + // "All" — fetch every document on a single page. + $this->args['rows_per_page'] = -1; + $this->args['offset'] = 0; + } else { + $this->args['rows_per_page'] = $per_page; + $this->args['offset'] = ( $page - 1 ) * $per_page; + } + + $this->set_post_args(); + + return $this->run_table_query( $this->build_table_query( $this->post_args ) ); + } + + /** + * Render the cards for a given page (the inner '.dlp-grid-documents' markup). + * + * @param int $page + * @return string + */ + public function get_grid_html( $page = 1 ) { + $posts = $this->get_page_posts( $page ); + $grid_class = sprintf( 'grid-columns columns-%d', (int) $this->args['grid_columns'] ); + $fields = $this->get_grid_fields(); + + $cards = ''; + + if ( ! empty( $posts ) && is_array( $posts ) ) { + foreach ( $posts as $_post ) { + setup_postdata( $_post ); + $cards .= $this->get_card( $_post, $fields ); + } + wp_reset_postdata(); + } else { + $cards = sprintf( '

%s

', esc_html__( 'No matching documents', 'document-library-lite' ) ); + } + + return sprintf( '
%s
', esc_attr( $grid_class ), $cards ); + } + + /** + * Render a single grid card. + * + * @param \WP_Post $_post + * @return string + */ + public function get_card( $_post, $fields = null ) { + if ( null === $fields ) { + $fields = $this->get_grid_fields(); + } + + $document = new Document( $_post->ID ); + + // Featured image (always rendered at the top of the card when enabled). + $image = in_array( 'image', $fields, true ) ? $this->get_card_image( $_post ) : ''; + $inner = $image; + $inner .= sprintf( '
', $image ? '' : ' no-image' ); + + if ( in_array( 'doc_categories', $fields, true ) ) { + $categories = $this->get_card_categories( $_post ); + if ( $categories ) { + $inner .= sprintf( '
%s
', $categories ); + } + } + + if ( in_array( 'title', $fields, true ) ) { + $inner .= sprintf( '
%s
', $this->get_card_title( $_post ) ); + } + + if ( in_array( 'content', $fields, true ) ) { + $excerpt = $this->get_card_excerpt( $_post ); + if ( '' !== $excerpt ) { + $inner .= sprintf( '
%s
', $excerpt ); + } + } + + if ( in_array( 'link', $fields, true ) ) { + $link = $this->get_card_download( $document ); + if ( $link ) { + $inner .= sprintf( '', $link ); + } + } + + $inner .= '
'; + + $card = sprintf( + '
%2$s
', + esc_attr( $_post->post_name ), + $inner + ); + + return apply_filters( 'document_library_grid_card', $card, $_post, $this->args ); + } + + /** + * Get the featured image (or file-type icon fallback) for a card. + * + * @param \WP_Post $post + * @return string + */ + private function get_card_image( $post ) { + $attachment_id = get_post_thumbnail_id( $post->ID ); + + if ( ! $attachment_id ) { + $document = new Document( $post->ID ); + return sprintf( + '', + $document->get_file_icon() + ); + } + + $full_src = wp_get_attachment_image_src( $attachment_id, apply_filters( 'document_library_image_full_size', 'full' ) ); + $atts = [ + 'title' => get_post_field( 'post_title', $attachment_id ), + 'alt' => trim( wp_strip_all_tags( get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ) ), + 'data-caption' => get_post_field( 'post_excerpt', $attachment_id ), + 'data-src' => $full_src[0], + 'data-large_image' => $full_src[0], + 'data-large_image_width' => $full_src[1], + 'data-large_image_height' => $full_src[2], + ]; + + $atts['data-caption'] = empty( $atts['data-caption'] ) ? trim( esc_attr( wp_strip_all_tags( $post->post_title ) ) ) : $atts['data-caption']; + $atts['alt'] = empty( $atts['alt'] ) ? $atts['data-caption'] : $atts['alt']; + + $image = wp_get_attachment_image( $attachment_id, apply_filters( 'document_library_image_grid_size', 'large' ), false, $atts ); + + // Lightbox wrapping mirrors the table renderer. + if ( ! empty( $this->args['lightbox'] ) ) { + $image = sprintf( '%2$s', esc_url( $full_src[0] ), $image ); + } + + return sprintf( '', $image ); + } + + /** + * Get the category list for a card, honouring the text-links new-tab option. + * + * @param \WP_Post $post + * @return string + */ + private function get_card_categories( $post ) { + $terms = get_the_terms( $post->ID, Taxonomies::CATEGORY_SLUG ); + + if ( ! $terms || is_wp_error( $terms ) ) { + return ''; + } + + // When search-on-click is enabled the link filters the grid (JS intercepts); otherwise it is a + // plain link to the category archive page. + $click_filter = ! empty( $this->args['search_on_click'] ); + $target = ! empty( $this->args['text_links_new_tab'] ) ? ' target="_blank" rel="noopener noreferrer"' : ''; + $links = []; + + foreach ( $terms as $term ) { + $term_link = get_term_link( $term ); + + if ( is_wp_error( $term_link ) ) { + continue; + } + + if ( $click_filter ) { + // The link carries the slug so the frontend JS can filter the grid by this category. + $links[] = sprintf( + '%4$s', + esc_url( $term_link ), + esc_attr( $term->slug ), + $target, + esc_html( $term->name ) + ); + } else { + $links[] = sprintf( + '%3$s', + esc_url( $term_link ), + $target, + esc_html( $term->name ) + ); + } + } + + return implode( ', ', $links ); + } + + /** + * Get the linked title for a card. + * + * @param \WP_Post $post + * @return string + */ + private function get_card_title( $post ) { + return esc_html( get_the_title( $post ) ); + } + + /** + * Get the truncated excerpt/content for a card. + * + * @param \WP_Post $post + * @return string + */ + private function get_card_excerpt( $post ) { + $num_words = isset( $this->args['content_length'] ) ? (int) $this->args['content_length'] : 15; + + $text = get_the_content( '', false, $post->ID ); + $text = strip_shortcodes( $text ); + $text = apply_filters( 'the_content', $text ); + + if ( $num_words > 0 ) { + $text = wp_trim_words( $text, $num_words, ' …' ); + } + + return $text; + } + + /** + * Get the download button for a card. + * + * @param Document $document + * @return string + */ + private function get_card_download( $document ) { + $link_text = isset( $this->args['link_text'] ) ? $this->args['link_text'] : __( 'Download', 'document-library-lite' ); + $link_style = isset( $this->args['link_style'] ) ? $this->args['link_style'] : 'button'; + $link_icon = isset( $this->args['link_icon'] ) ? $this->args['link_icon'] : false; + $link_target = isset( $this->args['link_target'] ) ? $this->args['link_target'] : false; + + return $document->get_download_button( $link_text, $link_style, $link_icon, $link_target ); + } + + /** + * Get the info markup, e.g. "Showing 1 to 10 of 12 entries" (matches the table layout). + * + * @param int $page The current 1-based page number. + * @return string + */ + public function get_totals_html( $page = 1 ) { + $total = (int) $this->get_total_posts(); + $per_page = $this->get_per_page(); + + if ( $total < 1 ) { + $start = 0; + $end = 0; + } elseif ( $per_page < 1 ) { + // "All" — single page showing every document. + $start = 1; + $end = $total; + } else { + $total_pages = (int) ceil( $total / max( 1, $per_page ) ); + $page = max( 1, min( (int) $page, max( 1, $total_pages ) ) ); + $start = ( $page - 1 ) * $per_page + 1; + $end = min( $page * $per_page, $total ); + } + + /* translators: 1: first row number, 2: last row number, 3: total number of documents. */ + $text = sprintf( + __( 'Showing %1$s to %2$s of %3$s entries', 'document-library-lite' ), + number_format_i18n( $start ), + number_format_i18n( $end ), + number_format_i18n( $total ) + ); + + return sprintf( '
%s
', esc_html( $text ) ); + } + + /** + * Get the pagination markup for a given page. + * + * @param int $page The current 1-based page number. + * @return string + */ + public function get_pagination_html( $page = 1 ) { + $total = (int) $this->get_total_posts(); + $per_page = $this->get_per_page(); + $total_pages = $per_page < 1 ? 1 : max( 1, (int) ceil( $total / max( 1, $per_page ) ) ); + $page = max( 1, min( (int) $page, $total_pages ) ); + + // Always render the control (single page = disabled Prev/Next), matching the table layout. + $buttons = $this->paginate_button( $page - 1, __( 'Previous', 'document-library-lite' ), 'previous', $page <= 1 ); + + for ( $i = 1; $i <= $total_pages; $i++ ) { + $buttons .= $this->paginate_button( $i, (string) $i, $i === $page ? 'current' : '', false ); + } + + $buttons .= $this->paginate_button( $page + 1, __( 'Next', 'document-library-lite' ), 'next', $page >= $total_pages ); + + return sprintf( '
%s
', $buttons ); + } + + /** + * Build a single pagination button. + * + * @param int $page_number + * @param string $label + * @param string $modifier Extra class, e.g. 'current', 'prev', 'next'. + * @param bool $disabled + * @return string + */ + private function paginate_button( $page_number, $label, $modifier = '', $disabled = false ) { + $classes = 'dlp-grid-paginate-button'; + + if ( $modifier ) { + $classes .= ' ' . $modifier; + } + + if ( $disabled ) { + $classes .= ' disabled'; + } + + return sprintf( + '%3$s', + esc_attr( $classes ), + (int) $page_number, + esc_html( $label ) + ); + } + + /** + * Render the full grid container (used on initial page load). + * + * @return string + */ + public function get_container() { + $grid = $this->get_grid_html( 1 ); + $totals = $this->get_totals_html( 1 ); + $pagination = $this->get_pagination_html( 1 ); + $active_cat = isset( $this->args['doc_category'] ) ? $this->args['doc_category'] : ''; + + return sprintf( + '
%4$s%5$s
', + esc_attr( $this->grid_id ), + esc_attr( isset( $this->args['scroll_offset'] ) ? $this->args['scroll_offset'] : 15 ), + esc_attr( $active_cat ), + $this->get_header_html(), + $grid, + $totals, + $pagination + ); + } + + /** + * Render the grid header (search box). Not re-rendered on AJAX so the input keeps focus/value. + * + * @return string + */ + private function get_header_html() { + // Page-length select on the left: "Show [select] entries". + $selected = $this->get_per_page(); + $options = ''; + foreach ( $this->get_page_lengths() as $length ) { + $options .= sprintf( '', $length, selected( $length, $selected, false ) ); + } + + // "All" option at the very end, only when the site is configured to show all documents (-1). + if ( -1 === $this->base_per_page ) { + $options .= sprintf( + '', + selected( $selected < 1, true, false ), + esc_html__( 'All', 'document-library-lite' ) + ); + } + + $length_html = sprintf( + '
', + esc_html__( 'Show', 'document-library-lite' ), + $options, + esc_html__( 'entries', 'document-library-lite' ) + ); + + // Search box on the right. + $search_html = sprintf( + '', + esc_html__( 'Search:', 'document-library-lite' ) + ); + + return sprintf( '
%1$s%2$s
', $length_html, $search_html ); + } + + /** + * Build the AJAX response parts for a given page. + * + * @param int $page + * @return array + */ + public function get_ajax_parts( $page = 1 ) { + return [ + 'grid' => $this->get_grid_html( $page ), + 'totals' => $this->get_totals_html( $page ), + 'pagination' => $this->get_pagination_html( $page ), + ]; + } + + /** + * Get the grid ID. + * + * @return string + */ + public function get_id() { + return $this->grid_id; + } +} diff --git a/src/Simple_Document_Library.php b/src/Simple_Document_Library.php index 9a23570..587d62d 100644 --- a/src/Simple_Document_Library.php +++ b/src/Simple_Document_Library.php @@ -14,6 +14,8 @@ */ class Simple_Document_Library { + use Document_Query; + public $args = []; public $post_args = []; private $total_posts = null; @@ -162,59 +164,6 @@ public function get_table( $output_type = 'html' ) { } } - /** - * Generate an inner array for the 'tax_query' arg in WP_Query. - * - * @param string $terms The list of terms as a string - * @param string $taxonomy The taxonomy name - * @param string $operator The SQL operator: IN, NOT IN, AND, etc - * @param string $field Add tax query by `term_id` or `slug`. Leave empty to auto-detect correct type - * @return array A tax query sub-array - */ - private function tax_query_item( $terms, $taxonomy, $operator = 'IN', $field = '' ) { - $and_relation = 'AND' === $operator; - - // comma-delimited list = OR, plus-delimited = AND - if ( ! is_array( $terms ) ) { - if ( false !== strpos( $terms, '+' ) ) { - $terms = explode( '+', $terms ); - $and_relation = true; - } else { - $terms = explode( ',', $terms ); - } - } - - // Do we have slugs or IDs? - if ( ! $field ) { - $using_term_ids = count( $terms ) === count( array_filter( $terms, 'is_numeric' ) ); - $field = $using_term_ids && ( ! isset( $this->args['numeric_terms'] ) || ! $this->args['numeric_terms'] ) ? 'term_id' : 'slug'; - } - - // Strange bug when using operator => 'AND' in individual tax queries - - // We need to separate these out into separate 'IN' arrays joined by and outer relation => 'AND' - if ( $and_relation && count( $terms ) > 1 ) { - $result = [ 'relation' => 'AND' ]; - - foreach ( $terms as $term ) { - $result[] = [ - 'taxonomy' => $taxonomy, - 'terms' => $term, - 'operator' => 'IN', - 'field' => $field, - ]; - } - - return $result; - } else { - return [ - 'taxonomy' => $taxonomy, - 'terms' => $terms, - 'operator' => $operator, - 'field' => $field, - ]; - } - } - /** * Get the document featured image. * @@ -249,8 +198,9 @@ private function get_image( $post, $args ) { $atts['alt'] = empty( $atts['alt'] ) ? $atts['title'] : $atts['alt']; $atts['alt'] = empty( $atts['alt'] ) ? trim( esc_attr( wp_strip_all_tags( $post->post_title ) ) ) : $atts['alt']; - // Get the image to display - $image = wp_get_attachment_image( $attachment_id, apply_filters( 'document_library_image_table_size', 'thumbnail' ), false, $atts ); + // Get the image to display. Use the configured WxH image size, falling back to 'thumbnail'. + $image_size = $this->parse_image_size( isset( $args['image_size'] ) ? $args['image_size'] : '' ); + $image = wp_get_attachment_image( $attachment_id, apply_filters( 'document_library_image_table_size', $image_size ), false, $atts ); } // Wrap image with lightbox markup or post link - lightbox takes priority over the 'links' option. @@ -261,6 +211,22 @@ private function get_image( $post, $args ) { return apply_filters( 'document_library_table_image', $image, $post ); } + /** + * Parse a "WxH" image size string into a [ width, height ] array for wp_get_attachment_image(). + * + * Falls back to the 'thumbnail' size keyword when the value is empty or malformed. + * + * @param string $image_size The image size string, e.g. '80x80'. + * @return array|string A [ width, height ] array, or the 'thumbnail' size keyword. + */ + private function parse_image_size( $image_size ) { + if ( is_string( $image_size ) && preg_match( '/^\s*(\d+)\s*[xX]\s*(\d+)\s*$/', $image_size, $matches ) ) { + return [ (int) $matches[1], (int) $matches[2] ]; + } + + return 'thumbnail'; + } + /** * Retrieve the post content, truncated to the number of words specified by $num_words. * @@ -288,55 +254,6 @@ public function get_orderby() { } } - public function run_table_query( $query_args ) { - do_action( 'document_library_before_posts_query', $this ); - - $query = get_posts( $query_args ); - - do_action( 'document_library_after_posts_query', $this ); - - return $query; - } - - public function build_table_query( $query_args ) { - if ( $this->args['lazy_load'] ) { - // Ensure rows per page doesn't exceed post limit - $query_args['posts_per_page'] = $this->check_within_post_limit( $this->args['rows_per_page'] ); - $query_args['offset'] = $this->args['offset']; - } else { - $query_args['posts_per_page'] = $this->args['post_limit']; - } - return apply_filters( 'document_library_table_query_args', $query_args, $this ); - } - - private function check_within_post_limit( $count ) { - return is_int( $this->args['post_limit'] ) && $this->args['post_limit'] > 0 ? min( $this->args['post_limit'], $count ) : $count; - } - - public function get_total_posts() { - if ( is_numeric( $this->total_posts ) ) { - return $this->total_posts; - } - - $total = 0; - - $total_query = new \WP_Query( $this->build_post_totals_query( $this->post_args ) ); - $total = $total_query->post_count; - - $this->total_posts = $this->check_within_post_limit( $total ); - - return $this->total_posts; - } - - private function build_post_totals_query( $args ) { - $query_args = $this->build_table_query( $args ); - $query_args['offset'] = 0; - $query_args['posts_per_page'] = -1; - $query_args['fields'] = 'ids'; - - return apply_filters( 'document_library_query_args', $query_args, $this ); - } - public function get_attributes() { $paging_attr = 'false'; diff --git a/src/Table/Ajax_Handler.php b/src/Table/Ajax_Handler.php index ab3904d..27ba013 100644 --- a/src/Table/Ajax_Handler.php +++ b/src/Table/Ajax_Handler.php @@ -2,11 +2,9 @@ namespace Barn2\Plugin\Document_Library\Table; -use Barn2\Plugin\Document_Library\Dependencies\Lib\Util; use Barn2\Plugin\Document_Library\Dependencies\Lib\Service\Standard_Service; -use Barn2\Plugin\Document_Library\Frontend_Scripts; use Barn2\Plugin\Document_Library\Simple_Document_Library; -use Barn2\Plugin\Document_Library\Util\Options; +use Barn2\Plugin\Document_Library\Simple_Document_Grid; /** * Handles AJAX requests for loading document library posts. @@ -22,6 +20,8 @@ class Ajax_Handler implements Standard_Service { public function __construct() { add_action( 'wp_ajax_dll_load_posts', [ $this, 'load_posts' ] ); add_action( 'wp_ajax_nopriv_dll_load_posts', [ $this, 'load_posts' ] ); + add_action( 'wp_ajax_dll_load_grid', [ $this, 'load_grid' ] ); + add_action( 'wp_ajax_nopriv_dll_load_grid', [ $this, 'load_grid' ] ); } public function register() { @@ -80,4 +80,66 @@ public function load_posts() { wp_send_json( $response ); } + /** + * Handle AJAX requests for loading a page of grid cards. + */ + public function load_grid() { + $nonce = ''; + if ( isset( $_POST['_ajax_nonce'] ) ) { + $nonce = sanitize_text_field( wp_unslash( $_POST['_ajax_nonce'] ) ); + } elseif ( isset( $_POST['ajax_nonce'] ) ) { + $nonce = sanitize_text_field( wp_unslash( $_POST['ajax_nonce'] ) ); + } + + if ( ! $nonce || ! wp_verify_nonce( $nonce, 'dll_load_grid' ) ) { + wp_send_json_error( [ 'message' => 'Security check failed' ], 403 ); + } + + if ( ! isset( $_POST['grid_id'] ) ) { + wp_send_json_error( [ 'message' => 'Grid ID is required' ], 400 ); + } + + $grid_id = sanitize_key( wp_unslash( $_POST['grid_id'] ) ); + + // Retrieve the stored configuration using the grid ID. + $args = Config_Builder::retrieve( $grid_id ); + + if ( false === $args ) { + wp_send_json_error( [ 'message' => 'Invalid or expired grid configuration' ], 404 ); + } + + $requested_status = isset( $args['status'] ) ? $args['status'] : 'publish'; + + // Unauthenticated users can ONLY see published content. + if ( ! is_user_logged_in() ) { + $args['status'] = 'publish'; + } elseif ( $requested_status !== 'publish' && ! current_user_can( 'edit_posts' ) ) { + wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 ); + } + + // Allow category filtering via the visible UI input. + if ( isset( $_POST['category'] ) && ! empty( $_POST['category'] ) ) { + $args['doc_category'] = sanitize_text_field( wp_unslash( $_POST['category'] ) ); + } + + // Free-text search. + if ( isset( $_POST['search_query'] ) ) { + $args['search_value'] = sanitize_text_field( wp_unslash( $_POST['search_query'] ) ); + } + + // Documents per page. + if ( isset( $_POST['length'] ) ) { + $length = (int) $_POST['length']; + if ( $length > 0 ) { + $args['requested_length'] = $length; + } + } + + $page = isset( $_POST['page_number'] ) ? max( 1, (int) $_POST['page_number'] ) : 1; + + $grid = new Simple_Document_Grid( $args, $grid_id ); + + wp_send_json( $grid->get_ajax_parts( $page ) ); + } + } diff --git a/src/Util/Options.php b/src/Util/Options.php index fbcdb9d..2af5506 100644 --- a/src/Util/Options.php +++ b/src/Util/Options.php @@ -29,7 +29,6 @@ final class Options { * @var array */ public static $readonly_settings = [ - 'layout', 'folders', 'document_link', 'link_destination', @@ -38,10 +37,8 @@ final class Options { 'preview', 'preview_style', 'preview_text', - 'image_size', 'shortcodes', 'excerpt_length', - 'content_length', 'post_limit', 'cache', 'cache_expiry', @@ -117,8 +114,12 @@ public static function get_default_settings() { $default_settings = [ 'link_text' => __( 'Download', 'document-library-lite' ), 'lightbox' => false, - 'link_style' => 'button', + 'link_style' => 'button', 'link_icon' => false, + 'layout' => 'table', + 'grid_columns' => 4, + 'grid_content' => 'image,title,content,link', + 'image_size' => '70x70', 'rows_per_page' => 20, 'sort_by' => 'date', 'sort_order' => '', @@ -129,11 +130,10 @@ public static function get_default_settings() { 'date_format' => 'Y/m/d', 'search_on_click' => true, 'wrap' => true, - 'content_length' => 15, 'scroll_offset' => 15, - 'post_limit' => 500, - 'offset' => 0, - 'lazy_load' => false + 'post_limit' => 500, + 'offset' => 0, + 'lazy_load' => false, ]; return $default_settings; diff --git a/webpack.config.js b/webpack.config.js index ad93ea1..547c03a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,16 +3,18 @@ const Barn2Configuration = require( '@barn2plugins/webpack-config' ); const config = new Barn2Configuration( [ - 'admin/document-library-post/index.js', - 'admin/document-library-settings/index.js', - 'admin/document-library-popover/index.js', - 'document-library-main.js' + 'admin/document-library-post/index.js', + 'admin/document-library-settings/index.js', + 'admin/document-library-popover/index.js', + 'document-library-main.js', + 'document-library-grid.js' ], [ - 'admin/document-library-import.scss', - 'admin/document-library-post.scss', - 'admin/document-library-settings.scss', - 'document-library-main.scss' + 'admin/document-library-import.scss', + 'admin/document-library-post.scss', + 'admin/document-library-settings.scss', + 'document-library-main.scss', + 'document-library-grid.scss' ], defaultConfig );