post_type) return $s->post_type;
$screen = adminlane_list_ajax_screen();
return $screen !== '' ? substr($screen, 5) : 'post';
}
add_action('admin_enqueue_scripts', function ($hook) {
if ($hook !== 'edit.php' || !adminlane_list_active()) return;
wp_enqueue_style('adl-list', ADMINLANE_URL . 'assets/list.css', ['adl-admin'], adminlane_ver('list.css'));
wp_enqueue_script('adl-list', ADMINLANE_URL . 'assets/list.js', ['wp-i18n', 'adl-admin'], adminlane_ver('list.js'), true);
wp_set_script_translations('adl-list', 'adminlane');
}, 21);
/* ---------------------------------------------------------------------
* Thumbnail column
* ------------------------------------------------------------------- */
function adminlane_list_add_column(array $columns, string $post_type): array {
if (!adminlane_list_context() || !post_type_supports($post_type, 'thumbnail')) return $columns;
// The list already shows a picture of its own (WooCommerce's products and the like).
foreach (array_keys($columns) as $key) {
if (preg_match('/thumb|image|photo|featured/i', (string) $key)) return $columns;
}
$out = [];
foreach ($columns as $key => $label) {
$out[$key] = $label;
if ($key === 'cb') $out['adminlane_thumb'] = '' . esc_html__('Image', 'adminlane') . '';
}
if (!isset($out['adminlane_thumb'])) $out = ['adminlane_thumb' => esc_html__('Image', 'adminlane')] + $out;
return $out;
}
// Added last, on the screen's own filter, so columns other plugins add are already there.
function adminlane_list_hook_columns(string $screen_id, string $post_type): void {
add_filter('manage_' . $screen_id . '_columns', function ($columns) use ($post_type) {
return adminlane_list_add_column((array) $columns, $post_type);
}, 99);
}
add_action('current_screen', function ($s) {
if ($s instanceof WP_Screen && $s->base === 'edit' && $s->post_type !== '') adminlane_list_hook_columns($s->id, $s->post_type);
});
add_action('admin_init', function () {
$screen = adminlane_list_ajax_screen();
if ($screen !== '') adminlane_list_hook_columns($screen, substr($screen, 5));
});
function adminlane_list_thumb_cell(string $column, int $post_id): void {
if ($column !== 'adminlane_thumb') return;
$post = get_post($post_id);
if (!$post) return;
$thumb = get_post_thumbnail_id($post);
$img = $thumb ? wp_get_attachment_image($thumb, [96, 96], false, ['class' => 'adl-thumb-img', 'loading' => 'lazy', 'decoding' => 'async', 'alt' => '']) : '';
if ($img === '') {
$title = trim(wp_strip_all_tags(get_the_title($post)));
$letter = $title !== '' ? mb_strtoupper(mb_substr($title, 0, 1)) : '·';
$img = '' . esc_html($letter) . '';
}
$link = current_user_can('edit_post', $post_id) && $post->post_status !== 'trash' ? get_edit_post_link($post_id) : '';
$html = $link
? '' . $img . ''
: '' . $img . '';
echo wp_kses($html, [
'a' => ['class' => true, 'href' => true, 'tabindex' => true, 'aria-hidden' => true],
'span' => ['class' => true, 'title' => true],
'img' => ['src' => true, 'srcset' => true, 'sizes' => true, 'width' => true, 'height' => true, 'alt' => true, 'class' => true, 'loading' => true, 'decoding' => true, 'fetchpriority' => true],
]);
}
add_action('manage_posts_custom_column', 'adminlane_list_thumb_cell', 10, 2);
add_action('manage_pages_custom_column', 'adminlane_list_thumb_cell', 10, 2);
/* ---------------------------------------------------------------------
* The status line under each title
* Printed into the row's (hidden) Quick Edit data, then shown by list.js.
* ------------------------------------------------------------------- */
/*
* WordPress has no hook between a row's title and its actions. The closest one is
* add_inline_data, which prints inside the row's hidden Quick Edit data, right
* after the title. So, last of all (after every other plugin's Quick Edit fields),
* the hidden box is closed, the status line printed, and an empty hidden box opened
* for WordPress to close. The line is then in place from the first paint — the rows
* don't grow after the page has loaded.
*/
add_action('add_inline_data', function ($post) {
if (!adminlane_list_context() || !$post instanceof WP_Post) return;
echo '';
echo wp_kses(adminlane_list_meta_line($post), [
'div' => ['class' => true],
'span' => ['class' => true, 'aria-hidden' => true],
]);
echo '
';
}, PHP_INT_MAX);
/* The row knows it has a status line from the start, so its actions show on hover in its place. */
add_filter('post_class', function ($classes) {
if (is_admin() && adminlane_list_context()) $classes[] = 'adl-has-meta';
return $classes;
});
function adminlane_list_meta_line(WP_Post $post): string {
$parts = [];
// The first category-like taxonomy: up to two terms, then "+n".
foreach (get_object_taxonomies($post, 'objects') as $tax) {
if (!$tax->hierarchical || !$tax->show_ui) continue;
$terms = get_the_terms($post, $tax->name);
if (!$terms || is_wp_error($terms)) break;
$chips = '';
foreach (array_slice($terms, 0, 2) as $t) {
$chips .= '
' . esc_html($t->name) . '';
}
if (count($terms) > 2) $chips .= '
+' . (count($terms) - 2) . '';
$parts[] = $chips;
break;
}
// get_post_timestamp() reads the local date with the site's time zone, so posts that
// were saved without the GMT date (imports, scripts, some AI tools) still get the right time.
$now = time();
$published = (int) get_post_timestamp($post, 'date');
$modified = (int) get_post_timestamp($post, 'modified');
if (!$modified) $modified = $published;
$day = (string) get_option('date_format');
switch ($post->post_status) {
case 'publish':
case 'private':
if ($published) {
/* translators: %s: date */
$parts[] = '
' . esc_html(sprintf(__('Published %s', 'adminlane'), wp_date($day, $published))) . '';
}
if ($published && $modified - $published > DAY_IN_SECONDS) {
/* translators: %s: time span, e.g. "3 days" */
$parts[] = '
' . esc_html(sprintf(__('updated %s ago', 'adminlane'), human_time_diff($modified, $now))) . '';
}
break;
case 'future':
if ($published) {
/* translators: %s: date and time */
$parts[] = '
' . esc_html(sprintf(__('Goes live %s', 'adminlane'), wp_date($day . ' ' . get_option('time_format'), $published))) . '';
}
break;
default:
if ($modified) {
/* translators: %s: time span, e.g. "3 days" */
$parts[] = '
' . esc_html(sprintf(__('Last edited %s ago', 'adminlane'), human_time_diff($modified, $now))) . '';
}
}
$text = trim(preg_replace('/\s+/u', ' ', wp_strip_all_tags(strip_shortcodes((string) $post->post_content))));
if ($text !== '') {
$words = count(preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY));
/* translators: %s: number of words */
$parts[] = '
' . esc_html(sprintf(_n('%s word', '%s words', $words, 'adminlane'), number_format_i18n($words))) . '';
}
if (!$parts) return '
';
// Each part is its own item; the dots between them come from the stylesheet, so a dot
// never ends up alone at the end or start of a line when the column is narrow.
return '
' . implode('', $parts) . '
';
}
/* ---------------------------------------------------------------------
* "No featured image" shortcut
* ------------------------------------------------------------------- */
/** A view of the list, like WordPress' own filters (read-only, so no nonce). */
function adminlane_list_no_image_active(): bool {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
return isset($_GET['adminlane_filter']) && sanitize_key(wp_unslash($_GET['adminlane_filter'])) === 'no-image';
}
add_action('pre_get_posts', function (WP_Query $q) {
if (!is_admin() || !$q->is_main_query() || ($GLOBALS['pagenow'] ?? '') !== 'edit.php' || !adminlane_list_no_image_active()) return;
$q->set('post_status', 'publish');
$q->set('meta_query', [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- only when this filter is chosen
'relation' => 'OR',
['key' => '_thumbnail_id', 'compare' => 'NOT EXISTS'],
['key' => '_thumbnail_id', 'value' => ['', '0'], 'compare' => 'IN'],
]);
});
function adminlane_list_count_no_image(string $post_type): int {
global $wpdb;
$key = 'noimg:' . $post_type . ':' . wp_cache_get_last_changed('posts');
$n = wp_cache_get($key, 'adminlane');
if ($n !== false) return (int) $n;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- one COUNT that WP_Query can't do cheaply; cached above.
$n = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_thumbnail_id'
WHERE p.post_type = %s AND p.post_status = 'publish' AND (m.meta_id IS NULL OR m.meta_value IN ('', '0'))",
$post_type
));
wp_cache_set($key, $n, 'adminlane', DAY_IN_SECONDS);
return $n;
}
/* ---------------------------------------------------------------------
* Overview on top
* Printed before the page; list.js moves it to the top of the page body.
* ------------------------------------------------------------------- */
add_action('all_admin_notices', function () {
if (!adminlane_list_active()) return;
$pt = adminlane_list_type();
$pto = get_post_type_object($pt);
if (!$pto) return;
$counts = wp_count_posts($pt, 'readable');
$c = static fn(string $s): int => (int) ($counts->$s ?? 0);
$base = $pt === 'post' ? admin_url('edit.php') : add_query_arg('post_type', $pt, admin_url('edit.php'));
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the list's own status view, read-only
$current = isset($_GET['post_status']) ? sanitize_key(wp_unslash($_GET['post_status'])) : '';
$tiles = [
['n' => $c('publish'), 'label' => __('Published', 'adminlane'), 'url' => add_query_arg('post_status', 'publish', $base), 'on' => $current === 'publish' && !adminlane_list_no_image_active()],
['n' => $c('draft'), 'label' => _n('Draft', 'Drafts', $c('draft'), 'adminlane'), 'url' => add_query_arg('post_status', 'draft', $base), 'on' => $current === 'draft'],
['n' => $c('future'), 'label' => __('Scheduled', 'adminlane'), 'url' => add_query_arg('post_status', 'future', $base), 'on' => $current === 'future'],
];
if (post_type_supports($pt, 'thumbnail')) {
$no = adminlane_list_count_no_image($pt);
$tiles[] = ['n' => $no, 'label' => __('Without featured image', 'adminlane'), 'url' => add_query_arg('adminlane_filter', 'no-image', $base), 'on' => adminlane_list_no_image_active(), 'warn' => $no > 0];
} else {
$tiles[] = ['n' => $c('pending'), 'label' => __('Pending review', 'adminlane'), 'url' => add_query_arg('post_status', 'pending', $base), 'on' => $current === 'pending'];
}
$sub = [];
if (!adminlane_list_no_image_active()) {
/* translators: %s: number of published items */
$sub[] = sprintf(_n('%s published', '%s published', $c('publish'), 'adminlane'), number_format_i18n($c('publish')));
$last = get_lastpostdate('gmt', $pt);
if ($last) {
/* translators: %s: time span, e.g. "3 days" */
$sub[] = sprintf(__('latest %s ago', 'adminlane'), human_time_diff((int) strtotime($last . ' UTC')));
}
}
?>