diff --git a/src/wp-admin/css/on-this-day.css b/src/wp-admin/css/on-this-day.css new file mode 100644 index 0000000000000..cd2f5b490bd67 --- /dev/null +++ b/src/wp-admin/css/on-this-day.css @@ -0,0 +1,380 @@ +/* ============================================================================= + On This Day dashboard widget + ============================================================================= + Organised as: + 1. Design tokens (custom properties on the widget root) + 2. Postbox chrome (#dashboard_on_this_day) + 3. Widget title date pill (`.hndle::after`) + 4. Timeline (year headers, vertical line, post rows) + 5. Post row (icon circle, title, excerpt, meta row) + 6. Window control + 7. Empty state + 8. Adaptive rules (reduced motion, small viewports) + ============================================================================= */ + +/* ----------------------------------------------------------------------------- + 1. Design tokens + + Scoped to the postbox so the title spans (rendered in `.hndle`, outside the + `.on-this-day-widget` wrapper) can reference the same variables as the body. + + Accent: `--wp-admin-theme-color*` are the only color custom properties core + exposes at runtime (see src/wp-admin/css/colors/_tokens.scss), so the widget + follows the user's selected admin color scheme (Blue, Modern, Coffee, etc.). + Fallback values match the classic "Fresh" scheme. + ----------------------------------------------------------------------------- */ +#dashboard_on_this_day, +.on-this-day-widget { + /* Accent — theme-color aware, follows the user's admin color scheme. */ + --otd-accent: var(--wp-admin-theme-color, #2271b1); + --otd-accent-dark: var(--wp-admin-theme-color-darker-10, #135e96); + --otd-accent-rgb: var(--wp-admin-theme-color--rgb, 34, 113, 177); + --otd-accent-8: rgba(var(--otd-accent-rgb), 0.08); + --otd-accent-15: rgba(var(--otd-accent-rgb), 0.15); + + /* Neutrals — classic wp-admin palette. */ + --otd-ink: #1d2327; + --otd-text: #2c3338; + --otd-muted: #646970; + --otd-subtle: #8c8f94; + --otd-line: #dcdcde; + + /* Semantic (theme-independent). */ + --otd-private: #b32d2e; + + /* Shape + motion. */ + --otd-pill: 9999px; + --otd-ease: 0.15s ease; +} + +/* ----------------------------------------------------------------------------- + 2. Postbox chrome + ----------------------------------------------------------------------------- */ +#dashboard_on_this_day { + /* To honour the postbox border-radius. */ + overflow: hidden; + + & .inside { + margin: 0; + padding: 0; + max-height: 560px; + display: flex; + flex-direction: column; + overflow: hidden; + } + + & .hndle { + gap: 0; + } +} + +.on-this-day-widget { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + font-size: 13px; + color: var(--otd-ink); + line-height: 1.5; +} + +/* Scrollable content area — keeps the timeline / empty state contained + so the window control below pins to the bottom of the postbox body. */ +.on-this-day-scroll { + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +/* ----------------------------------------------------------------------------- + 3. Widget title date pill + + The widget is registered with a plain-text title ("On This Day") so that + Screen Options and box-order preferences stay clean. The current date + ("F j") is injected into `--otd-today` as a quoted CSS string by PHP and + rendered here as a pseudo-element pill. The `none` fallback keeps the + pseudo-element hidden in contexts where the variable isn't set. + ----------------------------------------------------------------------------- */ +#dashboard_on_this_day .hndle::after { + content: var(--otd-today, none); + display: inline-block; + margin-left: 10px; + padding: 2px 9px; + font-weight: 600; + font-size: 11px; + letter-spacing: 0.3px; + text-transform: uppercase; + color: var(--otd-accent-dark); + background: var(--otd-accent-8); + border-radius: var(--otd-pill); + white-space: nowrap; + vertical-align: 1px; +} + +/* ----------------------------------------------------------------------------- + 4. Timeline + ----------------------------------------------------------------------------- + Layout math (reused below): + padding-left of .on-this-day-timeline = 20px + icon width = 28px -> centers at 20 + 14 = 34px + gap between icon and body = 14px -> body column starts at 20 + 28 + 14 = 62px + ----------------------------------------------------------------------------- */ +.on-this-day-timeline { + margin: 0; + padding: 12px 20px 16px; + list-style: none; +} + +.on-this-day-year-group { + list-style: none; + margin: 0; + padding: 0; +} + +.on-this-day-year-header { + margin: 8px 0 8px; + font-size: 15px; + font-weight: 500; + line-height: 1.4; + color: var(--otd-subtle); +} + +.on-this-day-year-number { + color: var(--otd-muted); +} + +.on-this-day-year-ago { + margin-left: 6px; + color: var(--otd-subtle); +} + +.on-this-day-post-list { + position: relative; + margin: 0; + padding: 0; + list-style: none; + + /* Vertical guide line, scoped per year group so the year header + naturally interrupts the line between groups. The halo on each + icon circle (see below) punches ~3px gaps around every circle. */ + &::before { + content: ""; + position: absolute; + left: 14px; /* center of the 28px icon column */ + top: 6px; + bottom: 14px; + width: 1px; + background: var(--otd-line); + } +} + +/* ----------------------------------------------------------------------------- + 5. Post row + ----------------------------------------------------------------------------- */ +.on-this-day-post { + display: grid; + grid-template-columns: 28px 1fr; + gap: 14px; + padding: 6px 0 14px; + + /* Icon circle on the left. */ + & .on-this-day-post-icon { + position: relative; + z-index: 1; /* sits above the vertical line */ + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: #fff; + border: 1px solid var(--otd-line); + color: var(--otd-muted); + + /* 3px white halo so the timeline line appears to stop short of + the circle on both sides rather than butting right into it. */ + box-shadow: 0 0 0 3px #fff; + + & svg { + display: block; + } + } + + /* Featured-image variant: the thumbnail fills the circle. + Border is hidden so the image reads as the "chip" itself, + while the 3px white halo still separates it from the + timeline line behind it. */ + & .on-this-day-post-icon.has-thumbnail { + overflow: hidden; + border-color: transparent; + background: var(--otd-line); /* shown briefly before the image loads */ + + & img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &.is-private .on-this-day-post-icon { + color: var(--otd-private); + border-color: #f5c9cc; + background: #fcf0f1; + } +} + +.on-this-day-post-body { + min-width: 0; +} + +.on-this-day-post-title { + margin: 0 0 2px; + font-size: 13px; + font-weight: 600; + line-height: 1.4; + + & a { + color: var(--otd-ink); + text-decoration: none; + box-shadow: none; + + &:hover, + &:focus { + color: var(--otd-accent); + } + } +} + +.on-this-day-post-excerpt { + margin: 0 0 6px; + color: var(--otd-text); + line-height: 1.5; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; +} + +.on-this-day-post-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-size: 12px; + color: var(--otd-subtle); + line-height: 1.5; +} + +.on-this-day-post-time { + color: var(--otd-muted); +} + +.on-this-day-post-sep { + color: var(--otd-line); +} + +.on-this-day-post-categories { + max-width: 240px; + color: var(--otd-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.on-this-day-post-private { + color: var(--otd-private); + font-weight: 500; +} + +/* Actions row sits on its own line below the meta, left-aligned + with the post body column. Uses core `.button` styling so it + picks up the user's admin color scheme automatically. */ +.on-this-day-post-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + align-items: center; +} + +/* ----------------------------------------------------------------------------- + 6. Window control + ----------------------------------------------------------------------------- */ +.on-this-day-window-form { + flex: 0 0 auto; + padding: 12px 20px 14px; + border-top: 1px solid var(--otd-line); + background: #fff; +} + +.on-this-day-window-control { + display: grid; + grid-template-columns: auto minmax(120px, 1fr) auto; + align-items: center; + gap: 8px; +} + +.on-this-day-window-control input[type="range"] { + width: 100%; + margin: 0; + accent-color: var(--otd-accent); +} + +.on-this-day-window-scale { + color: var(--otd-subtle); + font-size: 11px; + line-height: 1.4; + white-space: nowrap; +} + +/* ----------------------------------------------------------------------------- + 7. Empty state + ----------------------------------------------------------------------------- */ +.on-this-day-empty { + text-align: center; + padding: 28px 20px 24px; +} + +.on-this-day-empty-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + margin-bottom: 10px; + color: var(--otd-accent); + background: var(--otd-accent-8); + border-radius: 50%; + box-shadow: inset 0 0 0 1px var(--otd-accent-15); +} + +.on-this-day-empty-title { + margin: 6px 0 4px; + font-size: 15px; + font-weight: 600; + color: var(--otd-ink); +} + +.on-this-day-empty-text { + max-width: 340px; + margin: 0 auto 12px; + color: var(--otd-text); + line-height: 1.55; +} + +.on-this-day-empty-cta { + margin: 0; +} + +/* ----------------------------------------------------------------------------- + 8. Adaptive rules + ----------------------------------------------------------------------------- */ +@media (prefers-reduced-motion: reduce) { + .on-this-day-post-title a, + .on-this-day-post-action { + transition: none; + } +} + diff --git a/src/wp-admin/includes/class-wp-on-this-day.php b/src/wp-admin/includes/class-wp-on-this-day.php new file mode 100644 index 0000000000000..ac010e5750c10 --- /dev/null +++ b/src/wp-admin/includes/class-wp-on-this-day.php @@ -0,0 +1,606 @@ +'; + echo '
'; + // Already escaped at write time by the render_* methods below. + echo $cached; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
'; + self::render_window_control( $window_days ); + echo ''; + } + + /** + * Retrieves posts by a given author that were published in the + * selected date window in previous years. + * + * The "selected date window, prior year" constraint is expressed as a + * `date_query`: clauses pinning each `month`/`day`, combined with a + * `before` clause anchored to January 1 of the current year. + * + * @since 7.1.0 + * + * @param int $user_id Author ID to query posts for. + * @param int $window_days Number of days to include, starting with today. + * @return WP_Post[] Array of posts ordered by newest first. + */ + public static function get_posts( $user_id, $window_days = self::DEFAULT_WINDOW_DAYS ) { + $window_days = self::sanitize_window_days( $window_days ); + $year = (int) current_time( 'Y' ); + $date_query = array( + 'relation' => 'AND', + array( + 'before' => array( 'year' => $year ), + ), + array_merge( + array( 'relation' => 'OR' ), + self::get_window_date_query_clauses( $window_days ) + ), + ); + + $args = array( + 'author' => (int) $user_id, + 'post_type' => 'post', + 'post_status' => array( 'publish', 'private' ), + 'posts_per_page' => self::POSTS_PER_PAGE, + 'ignore_sticky_posts' => true, + 'orderby' => 'date', + 'order' => 'DESC', + 'no_found_rows' => true, + 'date_query' => $date_query, + ); + + /** + * Filters the arguments used to query posts for the On This Day dashboard widget. + * + * @since 7.1.0 + * + * @param array $args WP_Query arguments. + * @param int $user_id The author ID the query is scoped to. + * @param int $window_days Number of days included in the date window. + */ + $args = apply_filters( 'dashboard_on_this_day_query_args', $args, $user_id, $window_days ); + + $query = new WP_Query( $args ); + + return $query->posts; + } + + /** + * Handles date window preference form submissions. + * + * @since 7.1.0 + */ + public static function handle_window_days_submission() { + if ( + 'POST' !== $_SERVER['REQUEST_METHOD'] || + ! isset( $_POST['action'] ) || + 'set_on_this_day_window' !== sanitize_text_field( wp_unslash( $_POST['action'] ) ) + ) { + return; + } + + check_admin_referer( 'set-on-this-day-window' ); + + $window_days = isset( $_POST['on_this_day_window_days'] ) ? wp_unslash( $_POST['on_this_day_window_days'] ) : self::DEFAULT_WINDOW_DAYS; + $window_days = self::sanitize_window_days( $window_days ); + + update_user_meta( get_current_user_id(), self::WINDOW_DAYS_META_KEY, $window_days ); + + wp_safe_redirect( + add_query_arg( + 'on-this-day-window-updated', + '1', + admin_url( 'index.php' ) + ) + ); + exit; + } + + /** + * Renders the success admin notice after the date window preference is saved. + * + * Hooked to the `admin_notices` action and only outputs when the + * `on-this-day-window-updated` query argument is present. + * + * @since 7.1.0 + */ + public static function render_window_updated_notice() { + if ( ! isset( $_GET['on-this-day-window-updated'] ) ) { + return; + } + + $window_days = self::get_window_days(); + + wp_admin_notice( + sprintf( + /* translators: %s: Number of days. */ + __( 'On This Day duration updated to %s.' ), + sprintf( + /* translators: %s: Number of days. */ + _n( '%s day', '%s days', $window_days ), + number_format_i18n( $window_days ) + ) + ), + array( + 'id' => 'message', + 'type' => 'success', + 'dismissible' => true, + ) + ); + } + + /** + * Retrieves the current user's date window preference. + * + * @since 7.1.0 + * + * @param int $user_id User ID. + * @return int Number of days to include, between 1 and 7. + */ + public static function get_window_days( $user_id = 0 ) { + if ( ! $user_id ) { + $user_id = get_current_user_id(); + } + + $window_days = get_user_meta( $user_id, self::WINDOW_DAYS_META_KEY, true ); + + return self::sanitize_window_days( $window_days ); + } + + /** + * Returns a human-readable label for the active date window. + * + * @since 7.1.0 + * + * @param int $window_days Number of days included in the date window. + * @return string Date or date range label. + */ + public static function get_window_label( $window_days ) { + $window_days = self::sanitize_window_days( $window_days ); + $start = current_datetime(); + $start_label = wp_date( 'F j', $start->getTimestamp(), $start->getTimezone() ); + + if ( self::MIN_WINDOW_DAYS === $window_days ) { + return $start_label; + } + + $end = $start->modify( '+' . ( $window_days - 1 ) . ' days' ); + $end_label = wp_date( 'F j', $end->getTimestamp(), $end->getTimezone() ); + + return sprintf( + /* translators: 1: Start date, 2: End date. */ + __( '%1$s - %2$s' ), + $start_label, + $end_label + ); + } + + /** + * Sanitizes the date window size. + * + * @since 7.1.0 + * + * @param mixed $window_days Raw window size. + * @return int Number of days to include, between 1 and 7. + */ + protected static function sanitize_window_days( $window_days ) { + $window_days = absint( $window_days ); + + if ( $window_days < self::MIN_WINDOW_DAYS || $window_days > self::MAX_WINDOW_DAYS ) { + return self::DEFAULT_WINDOW_DAYS; + } + + return $window_days; + } + + /** + * Builds date query clauses for each day in the active window. + * + * @since 7.1.0 + * + * @param int $window_days Number of days included in the date window. + * @return array[] Date query clauses. + */ + protected static function get_window_date_query_clauses( $window_days ) { + $date = current_datetime(); + $clauses = array(); + + for ( $offset = 0; $offset < $window_days; $offset++ ) { + $day_date = $date->modify( '+' . $offset . ' days' ); + $clauses[] = array( + 'month' => (int) $day_date->format( 'n' ), + 'day' => (int) $day_date->format( 'j' ), + ); + } + + return $clauses; + } + + /** + * Renders the empty state shown when no matching posts exist. + * + * @since 7.1.0 + * + * @param int $window_days Number of days included in the date window. + */ + protected static function render_empty_state( $window_days ) { + ?> +
+ +

+

+ ' . esc_html( self::get_window_label( $window_days ) ) . '' + ); + ?> +

+

+ + + +

+
+ + + ID ); + $view_link = get_permalink( $post->ID ); + $status = get_post_status( $post ); + $is_private = ( 'private' === $status ); + + $title = get_the_title( $post ); + if ( '' === trim( $title ) ) { + $title = __( '(no title)' ); + } + + $excerpt = has_excerpt( $post ) ? $post->post_excerpt : $post->post_content; + $excerpt = wp_strip_all_tags( strip_shortcodes( $excerpt ) ); + $excerpt = preg_replace( '/\s+/', ' ', $excerpt ); + $excerpt = wp_trim_words( trim( $excerpt ), 24, '…' ); + + $date_str = get_the_date( 'F j', $post ); + $time_str = get_the_time( get_option( 'time_format' ), $post ); + $time_iso = get_the_time( 'c', $post ); + $categories = get_the_category( $post->ID ); + + $row_classes = 'on-this-day-post'; + if ( $is_private ) { + $row_classes .= ' is-private'; + } + ?> +
  • + + + + + + + +
    + + + + +

    + + + + + +

    + + +

    + + + + + +
    + + + + + + +
    + +
    +
  • + +
    + + +
    + + + +
    +
    + add( 'l10n', "/wp-admin/css/l10n$suffix.css" ); $styles->add( 'code-editor', "/wp-admin/css/code-editor$suffix.css", array( 'wp-codemirror' ) ); $styles->add( 'site-health', "/wp-admin/css/site-health$suffix.css" ); + $styles->add( 'on-this-day', "/wp-admin/css/on-this-day$suffix.css" ); $styles->add( 'wp-admin', false, array( 'dashicons', 'common', 'forms', 'admin-menu', 'dashboard', 'list-tables', 'edit', 'revisions', 'media', 'themes', 'about', 'nav-menus', 'widgets', 'site-icon', 'l10n', 'wp-base-styles' ) ); @@ -1855,6 +1856,7 @@ function wp_default_styles( $styles ) { 'customize-preview', 'login', 'site-health', + 'on-this-day', 'wp-empty-template-alert', // Includes CSS. 'buttons',