diff --git a/lib/experimental/dashboard-widgets/class-gutenberg-on-this-day.php b/lib/experimental/dashboard-widgets/class-gutenberg-on-this-day.php new file mode 100644 index 00000000000000..d1f4cfd55c4310 --- /dev/null +++ b/lib/experimental/dashboard-widgets/class-gutenberg-on-this-day.php @@ -0,0 +1,472 @@ + WP_REST_Server::READABLE, + 'callback' => 'gutenberg_on_this_day_rest_callback', + 'permission_callback' => function () { + return current_user_can( 'edit_posts' ); + }, + 'args' => array( + 'window_days' => array( + 'description' => __( 'Number of calendar days starting from today (1–7).', 'gutenberg' ), + 'type' => 'integer', + 'default' => GUTENBERG_ON_THIS_DAY_MIN_WINDOW, + 'minimum' => GUTENBERG_ON_THIS_DAY_MIN_WINDOW, + 'maximum' => GUTENBERG_ON_THIS_DAY_MAX_WINDOW, + ), + ), + ) + ); +} +add_action( 'rest_api_init', 'gutenberg_on_this_day_bootstrap' ); + +/** + * Clears cached On This Day payloads for a post author when a post is saved. + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + */ +function gutenberg_on_this_day_bust_cache_on_save( $post_id, $post ) { + if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { + return; + } + if ( ! $post instanceof WP_Post || 'post' !== $post->post_type ) { + return; + } + gutenberg_on_this_day_flush_user_cache( (int) $post->post_author ); +} +add_action( 'save_post_post', 'gutenberg_on_this_day_bust_cache_on_save', 10, 2 ); + +/** + * Clears cache when a post is deleted. + * + * @param int $post_id Post ID. + */ +function gutenberg_on_this_day_bust_cache_on_delete( $post_id ) { + $post = get_post( $post_id ); + if ( ! $post instanceof WP_Post || 'post' !== $post->post_type ) { + return; + } + gutenberg_on_this_day_flush_user_cache( (int) $post->post_author ); +} +add_action( 'delete_post', 'gutenberg_on_this_day_bust_cache_on_delete' ); + +/** + * Deletes cached REST payloads for every window size for the given user. + * + * @param int $user_id User ID. + */ +function gutenberg_on_this_day_flush_user_cache( $user_id ) { + $user_id = (int) $user_id; + if ( $user_id <= 0 ) { + return; + } + for ( $w = GUTENBERG_ON_THIS_DAY_MIN_WINDOW; $w <= GUTENBERG_ON_THIS_DAY_MAX_WINDOW; $w++ ) { + wp_cache_delete( gutenberg_on_this_day_cache_key( $user_id, $w ), GUTENBERG_ON_THIS_DAY_CACHE_GROUP ); + } +} + +/** + * Builds a stable cache key for a user + window pair. + * + * @param int $user_id User ID. + * @param int $window_days Window size. + * @return string Cache key. + */ +function gutenberg_on_this_day_cache_key( $user_id, $window_days ) { + return sprintf( 'payload_u%d_w%d', (int) $user_id, (int) $window_days ); +} + +/** + * REST callback: returns grouped posts, labels, and media metadata. + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ +function gutenberg_on_this_day_rest_callback( WP_REST_Request $request ) { + $user_id = get_current_user_id(); + if ( ! $user_id ) { + return new WP_Error( + 'rest_forbidden', + __( 'Sorry, you are not allowed to view On This Day data.', 'gutenberg' ), + array( 'status' => 401 ) + ); + } + + $window_days = (int) $request->get_param( 'window_days' ); + $window_days = gutenberg_on_this_day_clamp_window_days( $window_days ); + + $cache_key = gutenberg_on_this_day_cache_key( $user_id, $window_days ); + $cached = wp_cache_get( $cache_key, GUTENBERG_ON_THIS_DAY_CACHE_GROUP ); + if ( false !== $cached && is_array( $cached ) ) { + return rest_ensure_response( $cached ); + } + + $payload = gutenberg_on_this_day_build_payload( $user_id, $window_days ); + wp_cache_set( $cache_key, $payload, GUTENBERG_ON_THIS_DAY_CACHE_GROUP, GUTENBERG_ON_THIS_DAY_CACHE_TTL ); + + return rest_ensure_response( $payload ); +} + +/** + * Clamps window size to the supported range. + * + * @param mixed $window_days Raw value. + * @return int + */ +function gutenberg_on_this_day_clamp_window_days( $window_days ) { + $n = (int) $window_days; + return min( + GUTENBERG_ON_THIS_DAY_MAX_WINDOW, + max( GUTENBERG_ON_THIS_DAY_MIN_WINDOW, $n ) + ); +} + +/** + * Builds date_query OR-clauses for each calendar day in the window (site timezone). + * + * @param int $window_days Number of days. + * @return array> + */ +function gutenberg_on_this_day_get_window_date_query_clauses( $window_days ) { + $window_days = gutenberg_on_this_day_clamp_window_days( $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; +} + +/** + * Human-readable label for the active window (Core-style). + * + * @param int $window_days Window size. + * @return string + */ +function gutenberg_on_this_day_get_window_label( $window_days ) { + $window_days = gutenberg_on_this_day_clamp_window_days( $window_days ); + $start = current_datetime(); + $start_label = wp_date( 'F j', $start->getTimestamp(), $start->getTimezone() ); + + if ( 1 === $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', 'gutenberg' ), + $start_label, + $end_label + ); +} + +/** + * Plain-text excerpt using the HTML Tag Processor (Core-style). + * + * @param string $source HTML or post text. + * @param int $max_chars Max characters (Unicode). + * @return string + */ +function gutenberg_on_this_day_extract_excerpt_text( $source, $max_chars ) { + $source = strip_shortcodes( (string) $source ); + + if ( '' === trim( $source ) ) { + return ''; + } + + if ( ! class_exists( 'WP_HTML_Tag_Processor' ) ) { + $plain = wp_strip_all_tags( $source ); + return function_exists( 'mb_substr' ) + ? mb_substr( $plain, 0, $max_chars ) + : substr( $plain, 0, $max_chars ); + } + + $processor = new WP_HTML_Tag_Processor( $source ); + $parts = array(); + $length = 0; + $inline_tags = array( + 'A', + 'ABBR', + 'B', + 'BIG', + 'CODE', + 'DEL', + 'EM', + 'FONT', + 'I', + 'INS', + 'MARK', + 'Q', + 'S', + 'SAMP', + 'SMALL', + 'SPAN', + 'STRONG', + 'SUB', + 'SUP', + 'TIME', + 'VAR', + ); + + while ( $processor->next_token() ) { + $token_type = $processor->get_token_type(); + + if ( '#tag' === $token_type ) { + $tag_name = $processor->get_tag(); + + if ( ! in_array( $tag_name, $inline_tags, true ) ) { + $parts[] = ' '; + } + continue; + } + + if ( '#text' !== $token_type ) { + continue; + } + + $chunk = $processor->get_modifiable_text(); + $parts[] = $chunk; + $length += function_exists( 'mb_strlen' ) ? mb_strlen( $chunk ) : strlen( $chunk ); + + if ( $length >= $max_chars ) { + break; + } + } + + $separator = function_exists( '_wp_can_use_pcre_u' ) && _wp_can_use_pcre_u() + ? '~[\s\p{Z}]+~u' + : '~\s+~'; + + return trim( preg_replace( $separator, ' ', implode( '', $parts ) ) ); +} + +/** + * Runs WP_Query for the author’s posts in the anniversary window (previous years). + * + * @param int $user_id Author. + * @param int $window_days Window size. + * @return WP_Post[] + */ +function gutenberg_on_this_day_query_posts( $user_id, $window_days ) { + $window_days = gutenberg_on_this_day_clamp_window_days( $window_days ); + $year = (int) current_time( 'Y' ); + + $date_query = array( + 'relation' => 'AND', + array( + 'before' => array( 'year' => $year ), + ), + array_merge( + array( 'relation' => 'OR' ), + gutenberg_on_this_day_get_window_date_query_clauses( $window_days ) + ), + ); + + $args = array( + 'author' => (int) $user_id, + 'post_type' => 'post', + 'post_status' => array( 'publish', 'private', 'draft' ), + 'posts_per_page' => GUTENBERG_ON_THIS_DAY_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. + * + * @param array $args WP_Query arguments. + * @param int $user_id Author ID. + * @param int $window_days Window size. + */ + $args = apply_filters( 'dashboard_on_this_day_query_args', $args, $user_id, $window_days ); + + $query = new WP_Query( $args ); + + return $query->posts; +} + +/** + * Shapes REST payload: years → posts with links, excerpt, categories, thumbnail. + * + * @param int $user_id Current user (author scope). + * @param int $window_days Window size. + * @return array + */ +function gutenberg_on_this_day_build_payload( $user_id, $window_days ) { + $window_days = gutenberg_on_this_day_clamp_window_days( $window_days ); + $posts = gutenberg_on_this_day_query_posts( $user_id, $window_days ); + + $current_year = (int) current_time( 'Y' ); + $by_year = array(); + + foreach ( $posts as $post ) { + if ( ! ( $post instanceof WP_Post ) ) { + continue; + } + + $post_year = (int) get_the_date( 'Y', $post ); + if ( $post_year >= $current_year ) { + continue; + } + + if ( ! isset( $by_year[ $post_year ] ) ) { + $by_year[ $post_year ] = array(); + } + + $by_year[ $post_year ][] = gutenberg_on_this_day_shape_post( $post ); + } + + krsort( $by_year, SORT_NUMERIC ); + + $years = array(); + foreach ( $by_year as $year => $year_posts ) { + $years[] = array( + 'year' => (int) $year, + 'years_ago' => $current_year - (int) $year, + 'posts' => $year_posts, + ); + } + + return array( + 'window_days' => $window_days, + 'window_label' => gutenberg_on_this_day_get_window_label( $window_days ), + 'years' => $years, + ); +} + +/** + * Maps a single post to the REST item shape. + * + * @param WP_Post $post Post. + * @return array + */ +function gutenberg_on_this_day_shape_post( WP_Post $post ) { + $status = get_post_status( $post ); + + $title = get_the_title( $post ); + if ( '' === trim( (string) $title ) ) { + $title = __( '(no title)', 'gutenberg' ); + } + + $excerpt_source = has_excerpt( $post ) ? $post->post_excerpt : $post->post_content; + $excerpt = gutenberg_on_this_day_extract_excerpt_text( + $excerpt_source, + GUTENBERG_ON_THIS_DAY_EXCERPT_CHARS + ); + + $time_iso = get_the_time( 'c', $post ); + $time_display = get_the_time( get_option( 'time_format' ), $post ); + + $categories = array(); + foreach ( get_the_category( $post->ID ) as $term ) { + if ( ! ( $term instanceof WP_Term ) ) { + continue; + } + $link = get_term_link( $term ); + if ( is_wp_error( $link ) ) { + $link = ''; + } + $categories[] = array( + 'id' => (int) $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + 'link' => $link, + ); + } + + $thumb_id = get_post_thumbnail_id( $post ); + $thumb = null; + if ( $thumb_id ) { + $src = wp_get_attachment_image_src( $thumb_id, 'thumbnail' ); + if ( is_array( $src ) ) { + $alt = get_post_meta( $thumb_id, '_wp_attachment_image_alt', true ); + $srcset = wp_get_attachment_image_srcset( $thumb_id, 'thumbnail' ); + $thumb = array( + 'url' => $src[0], + 'width' => (int) $src[1], + 'height' => (int) $src[2], + 'srcset' => is_string( $srcset ) ? $srcset : '', + 'alt' => is_string( $alt ) ? $alt : '', + ); + } + } + + $edit_url = 'post.php?post=' . (int) $post->ID . '&action=edit'; + + $view_url = ''; + if ( 'publish' === $status ) { + $view_url = get_permalink( $post ); + } elseif ( in_array( $status, array( 'private', 'draft' ), true ) ) { + $preview = get_preview_post_link( $post ); + if ( is_string( $preview ) && '' !== $preview ) { + $view_url = $preview; + } + } + + return array( + 'id' => (int) $post->ID, + 'title' => $title, + 'status' => $status, + 'excerpt' => $excerpt, + 'time_iso' => $time_iso, + 'time_display' => $time_display, + 'edit_url' => $edit_url, + 'view_url' => $view_url ? $view_url : '', + 'categories' => $categories, + 'thumbnail' => $thumb, + ); +} diff --git a/lib/experimental/dashboard-widgets/class-wp-widget-type.php b/lib/experimental/dashboard-widgets/class-wp-widget-type.php index 142f2dd85955c1..afdc45727e593d 100644 --- a/lib/experimental/dashboard-widgets/class-wp-widget-type.php +++ b/lib/experimental/dashboard-widgets/class-wp-widget-type.php @@ -23,7 +23,7 @@ class WP_Widget_Type { /** - * Widget type key. Namespaced identifier, e.g. `core/hello-world`. + * Widget type key. Namespaced identifier, e.g. `core/on-this-day`. * * @var string */ diff --git a/lib/experimental/dashboard-widgets/default-layout-seed.php b/lib/experimental/dashboard-widgets/default-layout-seed.php index 3cafc34a45cbf1..118750722e3e86 100644 --- a/lib/experimental/dashboard-widgets/default-layout-seed.php +++ b/lib/experimental/dashboard-widgets/default-layout-seed.php @@ -11,7 +11,7 @@ */ /** - * Appends the bundled `core/hello-world` instance to the default layout + * Appends the bundled `core/on-this-day` instance to the default layout * unless something earlier in the filter chain already added it. * * Only contributes to the bundled `gutenberg_dashboard` surface; other @@ -30,16 +30,19 @@ function gutenberg_seed_default_dashboard_layout( $dashboard_layout, $dashboard_ $uuids = array_column( $dashboard_layout, 'uuid' ); - if ( in_array( 'default-hello-world-widget-instance', $uuids, true ) ) { + if ( in_array( 'default-on-this-day-widget-instance', $uuids, true ) ) { return $dashboard_layout; } $dashboard_layout[] = array( - 'uuid' => 'default-hello-world-widget-instance', - 'type' => 'core/hello-world', - 'placement' => array( + 'uuid' => 'default-on-this-day-widget-instance', + 'type' => 'core/on-this-day', + 'attributes' => array( + 'windowDays' => 1, + ), + 'placement' => array( 'width' => 2, - 'height' => 1, + 'height' => 2, ), ); diff --git a/lib/experimental/dashboard-widgets/load.php b/lib/experimental/dashboard-widgets/load.php index 635c18af15dbcd..0dc1693c0920e1 100644 --- a/lib/experimental/dashboard-widgets/load.php +++ b/lib/experimental/dashboard-widgets/load.php @@ -6,6 +6,71 @@ */ add_action( 'admin_menu', 'gutenberg_register_dashboard_widgets_menu' ); +add_action( 'dashboard_init', 'gutenberg_dashboard_widgets_register_site_health_widget' ); +add_action( 'dashboard-wp-admin_init', 'gutenberg_dashboard_widgets_register_site_health_widget' ); +add_action( 'dashboard_init', 'gutenberg_dashboard_widgets_register_activity_widget' ); +add_action( 'dashboard-wp-admin_init', 'gutenberg_dashboard_widgets_register_activity_widget' ); +add_action( 'dashboard_init', 'gutenberg_dashboard_widgets_register_quick_draft_widget' ); +add_action( 'dashboard-wp-admin_init', 'gutenberg_dashboard_widgets_register_quick_draft_widget' ); +add_action( 'dashboard_init', 'gutenberg_dashboard_widgets_register_site_preview_widget' ); +add_action( 'dashboard-wp-admin_init', 'gutenberg_dashboard_widgets_register_site_preview_widget' ); +add_action( 'dashboard_init', 'gutenberg_dashboard_widgets_register_welcome_widget' ); +add_action( 'dashboard-wp-admin_init', 'gutenberg_dashboard_widgets_register_welcome_widget' ); +add_action( 'dashboard_init', 'gutenberg_dashboard_widgets_register_events_widget' ); +add_action( 'dashboard-wp-admin_init', 'gutenberg_dashboard_widgets_register_events_widget' ); +add_action( 'dashboard_init', 'gutenberg_dashboard_widgets_register_news_widget' ); +add_action( 'dashboard-wp-admin_init', 'gutenberg_dashboard_widgets_register_news_widget' ); +add_action( 'dashboard_init', 'gutenberg_dashboard_widgets_register_hello_dolly_widget' ); +add_action( 'dashboard-wp-admin_init', 'gutenberg_dashboard_widgets_register_hello_dolly_widget' ); +add_action( 'dashboard_init', 'gutenberg_dashboard_widgets_register_on_this_day_widget' ); +add_action( 'dashboard-wp-admin_init', 'gutenberg_dashboard_widgets_register_on_this_day_widget' ); +add_action( 'admin_print_scripts', 'gutenberg_dashboard_widgets_print_config' ); + +/** + * Prints dashboard widget frontend config for dashboard screens. + */ +function gutenberg_dashboard_widgets_print_config() { + $screen = get_current_screen(); + + if ( ! $screen ) { + return; + } + + $is_dashboard_widgets_screen = in_array( + $screen->id, + array( 'dashboard', 'toplevel_page_dashboard-wp-admin' ), + true + ); + + if ( ! $is_dashboard_widgets_screen ) { + return; + } + + $config = array( + 'wpVersion' => explode( '-', wp_get_wp_version() )[0], + ); + ?> + + { diff --git a/widgets/activity/render.tsx b/widgets/activity/render.tsx new file mode 100644 index 00000000000000..d5137a5111c06b --- /dev/null +++ b/widgets/activity/render.tsx @@ -0,0 +1,312 @@ +/** + * WordPress dependencies + */ +import { useState, useMemo } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { __ } from '@wordpress/i18n'; +import { dateI18n, getDate } from '@wordpress/date'; +import { Spinner } from '@wordpress/components'; +import { Icon, comment, postList } from '@wordpress/icons'; +import { DataViews, filterSortAndPaginate } from '@wordpress/dataviews'; + +// Dashboard is still experimental. + +import { EmptyState, Link, Stack } from '@wordpress/ui'; +import type { View, Field } from '@wordpress/dataviews'; +import type { Post, Comment } from '@wordpress/core-data'; + +// ─── Item type ──────────────────────────────────────────────────────────────── + +type ActivityKind = 'post-future' | 'post-published' | 'comment'; + +type ActivityEvent = { + id: string; + // ISO date string — used for sorting and extracting the `date` group key. + datetime: string; + title: string; + description: string; + link: string; + kind: ActivityKind; +}; + +// ─── Date helpers ───────────────────────────────────────────────────────────── + +/** + * Formats a `YYYY-MM-DD` string into a human-readable group label: + * - Today / Yesterday / "Jun 15th" / "Jun 15th 2023" + * + * @param {string} dateStr YYYY-MM-DD date string. + */ +function formatGroupDate( dateStr: string ): string { + const now = getDate(); + const today = dateI18n( 'Y-m-d', now ); + + if ( dateStr === today ) { + return __( 'Today' ); + } + + const yesterday = getDate(); + yesterday.setDate( yesterday.getDate() - 1 ); + const yesterdayStr = dateI18n( 'Y-m-d', yesterday ); + + if ( dateStr === yesterdayStr ) { + return __( 'Yesterday' ); + } + + const currentYear = dateI18n( 'Y', now ); + + if ( dateStr.slice( 0, 4 ) === currentYear ) { + /* translators: Date format for dashboard activity group header (current year), see https://www.php.net/manual/datetime.format.php */ + return dateI18n( __( 'M jS' ), dateStr ); + } + + /* translators: Date format for dashboard activity group header (different year), see https://www.php.net/manual/datetime.format.php */ + return dateI18n( __( 'M jS Y' ), dateStr ); +} + +// ─── Fields ─────────────────────────────────────────────────────────────────── + +const FIELDS: Field< ActivityEvent >[] = [ + { + id: 'icon', + label: __( 'Icon' ), + type: 'media', + render: ( { item } ) => ( + + ), + enableSorting: false, + enableHiding: false, + }, + { + id: 'content', + label: __( 'Content' ), + getValue: ( { item } ) => item.title, + render: ( { item } ) => ( + <> + { item.title } + { item.description && ( + <> + { ': ' } + + + ) } + + ), + enableSorting: false, + enableGlobalSearch: true, + }, + { + id: 'time', + label: __( 'Time' ), + getValue: ( { item } ) => item.datetime, + render: ( { item } ) => ( + + { /* translators: Time format for activity stream, see https://www.php.net/manual/datetime.format.php */ } + { dateI18n( __( 'g:i a' ), item.datetime ) } + + ), + enableSorting: false, + }, + { + id: 'date', + label: __( 'Date' ), + getValue: ( { item } ) => item.datetime.split( 'T' )[ 0 ], + render: ( { item } ) => ( + { formatGroupDate( item.datetime.split( 'T' )[ 0 ] ) } + ), + enableSorting: false, + enableHiding: false, + }, +]; + +// ─── Default view ───────────────────────────────────────────────────────────── + +const DEFAULT_VIEW: View = { + type: 'activity', + search: '', + page: 1, + perPage: 20, + filters: [], + fields: [ 'time' ], + titleField: 'content', + mediaField: 'icon', + showMedia: true, + sort: { + field: 'datetime', + direction: 'desc', + }, + groupBy: { + field: 'date', + direction: 'desc', + showLabel: false, + }, +}; + +const FUTURE_POSTS_QUERY = { + status: 'future', + orderby: 'date', + order: 'asc', + per_page: 5, +}; + +const RECENT_POSTS_QUERY = { + status: 'publish', + orderby: 'date', + order: 'desc', + per_page: 5, +}; + +const COMMENTS_QUERY = { + per_page: 5, +}; + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function Activity() { + const [ view, setView ] = useState< View >( DEFAULT_VIEW ); + const { futurePosts, recentPosts, comments, isResolved } = useSelect( + ( select ) => { + const coreData = select( coreStore ); + + return { + futurePosts: coreData.getEntityRecords< Post >( + 'postType', + 'post', + FUTURE_POSTS_QUERY + ), + recentPosts: coreData.getEntityRecords< Post >( + 'postType', + 'post', + RECENT_POSTS_QUERY + ), + comments: coreData.getEntityRecords< Comment >( + 'root', + 'comment', + COMMENTS_QUERY + ), + isResolved: + coreData.hasFinishedResolution( 'getEntityRecords', [ + 'postType', + 'post', + FUTURE_POSTS_QUERY, + ] ) && + coreData.hasFinishedResolution( 'getEntityRecords', [ + 'postType', + 'post', + RECENT_POSTS_QUERY, + ] ) && + coreData.hasFinishedResolution( 'getEntityRecords', [ + 'root', + 'comment', + COMMENTS_QUERY, + ] ), + }; + }, + [] + ); + + const allEvents = useMemo< ActivityEvent[] >( () => { + const events: ActivityEvent[] = []; + + for ( const post of futurePosts ?? [] ) { + events.push( { + id: `post-future-${ post.id }`, + datetime: post.date ?? '', + title: ( post.title as { rendered: string } )?.rendered ?? '', + description: '', + link: post.link ?? '', + kind: 'post-future', + } ); + } + + for ( const post of recentPosts ?? [] ) { + events.push( { + id: `post-published-${ post.id }`, + datetime: post.date ?? '', + title: ( post.title as { rendered: string } )?.rendered ?? '', + description: '', + link: post.link ?? '', + kind: 'post-published', + } ); + } + + for ( const c of comments ?? [] ) { + events.push( { + id: `comment-${ c.id }`, + datetime: ( c.date as string ) ?? '', + title: ( c.author_name as string ) ?? '', + description: + ( c.content as { rendered: string } )?.rendered ?? '', + link: ( c.link as string ) ?? '', + kind: 'comment', + } ); + } + + return events; + }, [ futurePosts, recentPosts, comments ] ); + + const { data: shownData, paginationInfo } = useMemo( + () => filterSortAndPaginate( allEvents, view, FIELDS ), + [ allEvents, view ] + ); + + if ( ! isResolved ) { + return ( + + + + ); + } + + if ( allEvents.length === 0 ) { + return ( + + + + + { __( 'No activity yet.' ) } + + + { __( + 'When you publish posts or receive comments, they will appear here.' + ) } + + + + ); + } + + return ( + item.id } + search={ false } + isLoading={ false } + defaultLayouts={ { + activity: { + sort: { + field: 'datetime', + direction: 'desc', + }, + }, + } } + renderItemLink={ ( { item, children, ...aProps } ) => ( + + { children } + + ) } + isItemClickable={ ( item ) => !! item.link } + > + + + ); +} diff --git a/widgets/activity/widget.json b/widgets/activity/widget.json new file mode 100644 index 00000000000000..0de04576b3a9a4 --- /dev/null +++ b/widgets/activity/widget.json @@ -0,0 +1,6 @@ +{ + "name": "core/activity", + "title": "Activity", + "description": "Displays recently published posts, scheduled posts, and recent comments.", + "category": "dashboard" +} diff --git a/widgets/activity/widget.ts b/widgets/activity/widget.ts new file mode 100644 index 00000000000000..29a012a84a5922 --- /dev/null +++ b/widgets/activity/widget.ts @@ -0,0 +1,6 @@ +import { __ } from '@wordpress/i18n'; + +export default { + name: 'core/activity', + title: __( 'Activity' ), +}; diff --git a/widgets/events/list.tsx b/widgets/events/list.tsx new file mode 100644 index 00000000000000..349994e8130d6f --- /dev/null +++ b/widgets/events/list.tsx @@ -0,0 +1,83 @@ +/** + * WordPress dependencies + */ +import type { ReactNode } from 'react'; +import { Link, Stack, Text } from '@wordpress/ui'; + +/** + * Internal dependencies + */ +import styles from './style.module.css'; + +export type ListItem = { + id: string; + title: string; + url: string; + meta?: string[]; + icon?: ReactNode; +}; + +export default function List( { + items, + empty, +}: { + items: ListItem[]; + empty?: ReactNode; +} ) { + if ( items.length === 0 ) { + return empty ?? null; + } + + return ( + + { items.map( ( item ) => ( + + { item.icon && ( +
+ { item.icon } +
+ ) } + + + { item.url ? ( + + { item.title } + + ) : ( + item.title + ) } + + { item.meta && item.meta.length > 0 && ( + + { item.meta.map( ( metaItem, index ) => ( + + { index > 0 && '· ' } + { metaItem } + + ) ) } + + ) } + +
+ ) ) } +
+ ); +} diff --git a/widgets/events/render.tsx b/widgets/events/render.tsx new file mode 100644 index 00000000000000..d5b67f80515cdb --- /dev/null +++ b/widgets/events/render.tsx @@ -0,0 +1,618 @@ +/** + * WordPress dependencies + */ +import { + useId, + useState, + useEffect, + createInterpolateElement, +} from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { dateI18n, format } from '@wordpress/date'; +import { __, _x, sprintf } from '@wordpress/i18n'; +import { calendar, mapMarker, wordpress, people } from '@wordpress/icons'; +import { Spinner } from '@wordpress/components'; +/* eslint-disable @wordpress/use-recommended-components */ +import { + Autocomplete, + Button, + Card, + EmptyState, + Icon, + IconButton, + InputControl, + InputLayout, + Link, + Stack, + Text, +} from '@wordpress/ui'; +/* eslint-enable @wordpress/use-recommended-components */ + +/** + * Internal dependencies + */ +import styles from './style.module.css'; +import List, { type ListItem } from './list'; + +interface WPEvent { + type: 'wordcamp' | 'meetup' | 'online' | string; + title: string; + url: string; + date: string; + start_unix_timestamp?: number; + end_unix_timestamp?: number; + location: { + description: string; + country: string; + }; + user_formatted_date: string; + user_formatted_time?: string; + timeZoneAbbreviation?: string; +} + +interface WPEventsResponse { + events: WPEvent[]; + location?: { + description: string; + }; +} + +const EVENTS_API = 'https://api.wordpress.org/events/1.0/'; +type LocationOption = { + id: string; + value: string; +}; + +function formatEventType( type: string ): string { + if ( type === 'wordcamp' ) { + return 'WordCamp'; + } + if ( type === 'meetup' ) { + return __( 'Meetup' ); + } + return type.charAt( 0 ).toUpperCase() + type.slice( 1 ); +} + +function getFlippedTimeZoneOffset( startTimestamp: number ): number { + return new Date( startTimestamp ).getTimezoneOffset() * -1; +} + +function getTimeZone( startTimestamp: number ): string | number { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + if ( typeof timeZone === 'undefined' ) { + return getFlippedTimeZoneOffset( startTimestamp ); + } + + return timeZone; +} + +function getFormattedDate( + startDate: number, + endDate?: number, + timeZone?: string | number +): string { + let formattedDate: string; + + /* translators: Date format for upcoming events on the dashboard. Include the day of the week. See https://www.php.net/manual/datetime.format.php */ + const singleDayEvent = __( 'l, M j, Y' ); + /* translators: Date string for upcoming events. 1: Month, 2: Starting day, 3: Ending day, 4: Year. */ + const multipleDayEvent = __( '%1$s %2$d–%3$d, %4$d' ); + /* translators: Date string for upcoming events. 1: Starting month, 2: Starting day, 3: Ending month, 4: Ending day, 5: Ending year. */ + const multipleMonthEvent = __( '%1$s %2$d – %3$s %4$d, %5$d' ); + + if ( + ! endDate || + format( 'Y-m-d', startDate ) === format( 'Y-m-d', endDate ) + ) { + formattedDate = dateI18n( singleDayEvent, startDate, timeZone ); + } else if ( format( 'Y-m', startDate ) === format( 'Y-m', endDate ) ) { + formattedDate = sprintf( + multipleDayEvent, + dateI18n( + _x( 'F', 'upcoming events month format' ), + startDate, + timeZone + ), + Number( + dateI18n( + _x( 'j', 'upcoming events day format' ), + startDate, + timeZone + ) + ), + Number( + dateI18n( + _x( 'j', 'upcoming events day format' ), + endDate, + timeZone + ) + ), + Number( + dateI18n( + _x( 'Y', 'upcoming events year format' ), + endDate, + timeZone + ) + ) + ); + } else { + formattedDate = sprintf( + multipleMonthEvent, + dateI18n( + _x( 'F', 'upcoming events month format' ), + startDate, + timeZone + ), + Number( + dateI18n( + _x( 'j', 'upcoming events day format' ), + startDate, + timeZone + ) + ), + dateI18n( + _x( 'F', 'upcoming events month format' ), + endDate, + timeZone + ), + Number( + dateI18n( + _x( 'j', 'upcoming events day format' ), + endDate, + timeZone + ) + ), + Number( + dateI18n( + _x( 'Y', 'upcoming events year format' ), + endDate, + timeZone + ) + ) + ); + } + + return formattedDate; +} + +function EventsList( { + events, + loading, + error, + showEmptyState, +}: { + events: WPEvent[]; + loading: boolean; + error: boolean; + showEmptyState: boolean; +} ) { + if ( loading ) { + return ( + + + + ); + } + + if ( error ) { + return ( +

+ { __( 'An error occurred. Please try again.' ) } +

+ ); + } + + const organizeUrl = __( + 'https://make.wordpress.org/community/organize-event-landing-page/' + ); + + const emptyState = ( + + + + + { __( 'No events near you' ) } + + + { createInterpolateElement( + __( 'Help organize the next one!' ), + { + a: , + } + ) } + + + + ); + + const items: ListItem[] = events.map( ( event ) => { + const startDate = event.start_unix_timestamp + ? event.start_unix_timestamp * 1000 + : Date.parse( event.date ); + const endDate = event.end_unix_timestamp + ? event.end_unix_timestamp * 1000 + : undefined; + const timeZone = getTimeZone( startDate ); + const formattedDate = Number.isNaN( startDate ) + ? event.user_formatted_date || dateI18n( 'M j, Y', event.date ) + : getFormattedDate( startDate, endDate, timeZone ); + + return { + id: event.url, + title: event.title, + url: event.url, + icon: + event.type === 'wordcamp' ? ( + + ) : ( + + ), + meta: [ + event.type !== 'online' ? formatEventType( event.type ) : null, + event.location.description, + formattedDate, + event.user_formatted_time, + ].filter( Boolean ) as string[], + }; + } ); + + return ( + <> + + { events.length > 0 && events.length <= 2 && ( + + { createInterpolateElement( + __( + 'Want more events? Help organize the next one!' + ), + { + a: , + } + ) } + + ) } + + ); +} + +export default function WordPressEvents() { + const locationInputId = useId(); + const userLocale = useSelect( + ( select ) => + ( ( select( coreStore ) as any ).getCurrentUser() + ?.locale as string ) ?? 'en_US', + [] + ); + + const [ locationInput, setLocationInput ] = useState( '' ); + const [ locationOptions, setLocationOptions ] = useState< + LocationOption[] + >( [] ); + const [ activeLocation, setActiveLocation ] = useState( '' ); + const [ locationLabel, setLocationLabel ] = useState( '' ); + const [ isEditingLocation, setIsEditingLocation ] = useState( false ); + const [ isLocatingCity, setIsLocatingCity ] = useState( false ); + const [ events, setEvents ] = useState< WPEvent[] >( [] ); + const [ eventsLoading, setEventsLoading ] = useState( true ); + const [ eventsError, setEventsError ] = useState( false ); + + const fillCityFromGeolocation = async () => { + if ( ! navigator.geolocation || isLocatingCity ) { + return; + } + + setIsLocatingCity( true ); + + try { + const position = await new Promise< GeolocationPosition >( + ( resolve, reject ) => { + navigator.geolocation.getCurrentPosition( resolve, reject, { + enableHighAccuracy: false, + timeout: 10000, + } ); + } + ); + + const { latitude, longitude } = position.coords; + const response = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${ latitude }&lon=${ longitude }` + ); + const data = ( await response.json() ) as { + address?: { + city?: string; + town?: string; + village?: string; + municipality?: string; + }; + }; + + const city = + data.address?.city ?? + data.address?.town ?? + data.address?.village ?? + data.address?.municipality; + + if ( city ) { + setLocationInput( city ); + } + } catch { + // No-op: keep manual location entry as fallback. + } finally { + setIsLocatingCity( false ); + } + }; + + useEffect( () => { + const query = locationInput.trim(); + + if ( query.length < 2 ) { + setLocationOptions( [] ); + return; + } + + const controller = new AbortController(); + const timeoutId = setTimeout( async () => { + try { + const params = new URLSearchParams( { + q: query, + featureType: 'city', + format: 'jsonv2', + addressdetails: '1', + limit: '8', + } ); + const response = await fetch( + `https://nominatim.openstreetmap.org/search?${ params }`, + { signal: controller.signal } + ); + const data = ( await response.json() ) as Array< { + place_id: number; + address?: { + city?: string; + town?: string; + village?: string; + municipality?: string; + country?: string; + }; + } >; + + const seen = new Set< string >(); + const nextOptions = data + .map( ( place ) => { + const city = + place.address?.city ?? + place.address?.town ?? + place.address?.village ?? + place.address?.municipality; + const country = place.address?.country; + + if ( ! city ) { + return null; + } + + const label = country + ? `${ city }, ${ country }` + : city; + if ( seen.has( label.toLowerCase() ) ) { + return null; + } + + seen.add( label.toLowerCase() ); + return { + id: String( place.place_id ), + value: label, + }; + } ) + .filter( Boolean ) as LocationOption[]; + + setLocationOptions( nextOptions ); + } catch ( error: unknown ) { + if ( error instanceof Error && error.name === 'AbortError' ) { + return; + } + setLocationOptions( [] ); + } + }, 200 ); + + return () => { + clearTimeout( timeoutId ); + controller.abort(); + }; + }, [ locationInput ] ); + + useEffect( () => { + const controller = new AbortController(); + setEventsLoading( true ); + setEventsError( false ); + + const params = new URLSearchParams( { + number: '5', + locale: userLocale, + } ); + if ( activeLocation ) { + params.set( 'location', activeLocation ); + } + + fetch( `${ EVENTS_API }?${ params }`, { signal: controller.signal } ) + .then( ( r ) => r.json() as Promise< WPEventsResponse > ) + .then( ( data ) => { + setEvents( data.events ?? [] ); + if ( data.location?.description ) { + setLocationLabel( data.location.description ); + } + setEventsLoading( false ); + } ) + .catch( ( err: Error ) => { + if ( err.name !== 'AbortError' ) { + setEventsError( true ); + setEventsLoading( false ); + } + } ); + + return () => controller.abort(); + }, [ activeLocation, userLocale ] ); + + return ( + + { ! locationLabel || isEditingLocation ? ( +
+
{ + e.preventDefault(); + setActiveLocation( locationInput ); + setIsEditingLocation( false ); + } } + > + + + {} } + suffix={ + + + + + } + /> + } + placeholder={ __( 'City, like Tokyo…' ) } + /> + { locationOptions.length > 0 && ( + + + + + { ( item: { + id: string; + value: string; + } ) => ( + + { item.value } + + ) } + + + + + ) } + + + { isEditingLocation && ( + + ) } + +
+
+ ) : null } + + + { locationLabel && ! isEditingLocation && ( +
+ { createInterpolateElement( + sprintf( + /* translators: %s: city name */ + __( + 'Upcoming events near %s.' + ), + locationLabel + ), + { + strong: , + } + ) }{ ' ' } + { + setLocationInput( activeLocation ); + setIsEditingLocation( true ); + } } + > + { __( 'Change' ) } + +
+ ) } + + + { __( 'Meetups' ) } + + + { __( 'WordCamps' ) } + + +
+
+ ); +} diff --git a/widgets/events/style.module.css b/widgets/events/style.module.css new file mode 100644 index 00000000000000..14472de48f5765 --- /dev/null +++ b/widgets/events/style.module.css @@ -0,0 +1,51 @@ + +.locationPicker { + margin-block-end: 12px; + max-width: 400px; +} + +.locationInput { + flex: 1; + min-inline-size: 120px; +} + +.eventNone { + padding-block: 8px; + color: var(--wpds-color-fg-content-neutral-weak); +} + +.statusText { + color: var(--wpds-color-fg-content-neutral-weak); + margin: 0; +} + +.footer { + padding: 12px 0 0; + margin: 12px 0 0; + border-block-start: 1px solid var(--wpds-color-stroke-surface-neutral); +} + +.footerLinks { + margin-inline-start: auto; +} + +.listItem { + inline-size: 100%; +} + +.listItemIcon { + inline-size: 36px; + block-size: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: var(--wpds-color-bg-track-neutral-weak); + color: var(--wpds-color-fg-content-neutral-weak); + flex-shrink: 0; +} + +.listItemContent { + flex: 1; + min-inline-size: 0; +} diff --git a/widgets/events/widget.json b/widgets/events/widget.json new file mode 100644 index 00000000000000..2e44ac47528d4c --- /dev/null +++ b/widgets/events/widget.json @@ -0,0 +1,6 @@ +{ + "name": "core/events", + "title": "WordPress events", + "description": "Displays upcoming WordPress community events.", + "category": "dashboard" +} diff --git a/widgets/events/widget.ts b/widgets/events/widget.ts new file mode 100644 index 00000000000000..53993562d87a91 --- /dev/null +++ b/widgets/events/widget.ts @@ -0,0 +1,6 @@ +import { __ } from '@wordpress/i18n'; + +export default { + name: 'core/events', + title: __( 'WordPress events' ), +}; diff --git a/widgets/hello-dolly/README.md b/widgets/hello-dolly/README.md new file mode 100644 index 00000000000000..ef9a3d3d27af4e --- /dev/null +++ b/widgets/hello-dolly/README.md @@ -0,0 +1,3 @@ +# Hello Dolly widget + +Adopted from [Hello Dolly](https://github.com/WordPress/WordPress/blob/master/wp-content/plugins/hello.php) plugin. diff --git a/widgets/hello-dolly/render.tsx b/widgets/hello-dolly/render.tsx new file mode 100644 index 00000000000000..e23afe4abceece --- /dev/null +++ b/widgets/hello-dolly/render.tsx @@ -0,0 +1,72 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Card, Stack, Text, VisuallyHidden } from '@wordpress/ui'; + +// These are the lyrics to Hello Dolly +const DOLLY_LYRICS = [ + 'Hello, Dolly', + 'Well, hello, Dolly', + "It's so nice to have you back where you belong", + "You're lookin' swell, Dolly", + 'I can tell, Dolly', + "You're still glowin', you're still crowin'", + "You're still goin' strong", + "I feel the room swayin'", + "While the band's playin'", + 'One of our old favorite songs from way back when', + 'So, take her wrap, fellas', + 'Dolly, never go away again', + 'Hello, Dolly', + 'Well, hello, Dolly', + "It's so nice to have you back where you belong", + "You're lookin' swell, Dolly", + 'I can tell, Dolly', + "You're still glowin', you're still crowin'", + "You're still goin' strong", + "I feel the room swayin'", + "While the band's playin'", + 'One of our old favorite songs from way back when', + 'So, golly, gee, fellas', + 'Have a little faith in me, fellas', + 'Dolly, never go away', + "Promise, you'll never go away", + "Dolly'll never go away again", +]; + +export default function HelloDolly() { + // Randomly choose a line. + const quote = useMemo( () => { + return DOLLY_LYRICS[ + Math.floor( Math.random() * DOLLY_LYRICS.length ) + ]; + }, [] ); + + // Echoes and positions the chosen line of lyrics. + return ( + + + } + style={ { + textAlign: 'center', + fontStyle: 'italic', + fontFamily: 'Georgia, "Times New Roman", Times, serif', + } } + > + }> + { __( + 'Quote from Hello Dolly song, by Jerry Herman:' + ) }{ ' ' } + + + { quote } + + + + + ); +} diff --git a/widgets/hello-dolly/widget.json b/widgets/hello-dolly/widget.json new file mode 100644 index 00000000000000..5d0cacdb718d4c --- /dev/null +++ b/widgets/hello-dolly/widget.json @@ -0,0 +1,6 @@ +{ + "name": "core/hello-dolly", + "title": "Hello Dolly", + "description": "This is not just a widget, it symbolizes the hope and enthusiasm of an entire generation summed up in two words sung most famously by Louis Armstrong: Hello, Dolly. When activated you will randomly see a lyric from Hello, Dolly in the dahboard.", + "category": "dashboard" +} diff --git a/widgets/hello-world/render.tsx b/widgets/hello-world/render.tsx deleted file mode 100644 index 9d5dcd6259e033..00000000000000 --- a/widgets/hello-world/render.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function HelloWorld() { - return
Hello World
; -} diff --git a/widgets/hello-world/widget.json b/widgets/hello-world/widget.json deleted file mode 100644 index f94cc461931ff0..00000000000000 --- a/widgets/hello-world/widget.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "core/hello-world", - "title": "Hello World", - "description": "A minimal example widget.", - "category": "demo" -} diff --git a/widgets/hello-world/widget.ts b/widgets/hello-world/widget.ts deleted file mode 100644 index 7e349a6a6cc7fd..00000000000000 --- a/widgets/hello-world/widget.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Widget type definition - */ -export default { - name: 'core/hello-world', - title: 'Hello World', - icon: 'wordpress', -}; diff --git a/widgets/lock-unlock.ts b/widgets/lock-unlock.ts new file mode 100644 index 00000000000000..54c16989c35575 --- /dev/null +++ b/widgets/lock-unlock.ts @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.', + '@wordpress/widgets' + ); diff --git a/widgets/news/list.tsx b/widgets/news/list.tsx new file mode 100644 index 00000000000000..349994e8130d6f --- /dev/null +++ b/widgets/news/list.tsx @@ -0,0 +1,83 @@ +/** + * WordPress dependencies + */ +import type { ReactNode } from 'react'; +import { Link, Stack, Text } from '@wordpress/ui'; + +/** + * Internal dependencies + */ +import styles from './style.module.css'; + +export type ListItem = { + id: string; + title: string; + url: string; + meta?: string[]; + icon?: ReactNode; +}; + +export default function List( { + items, + empty, +}: { + items: ListItem[]; + empty?: ReactNode; +} ) { + if ( items.length === 0 ) { + return empty ?? null; + } + + return ( + + { items.map( ( item ) => ( + + { item.icon && ( +
+ { item.icon } +
+ ) } + + + { item.url ? ( + + { item.title } + + ) : ( + item.title + ) } + + { item.meta && item.meta.length > 0 && ( + + { item.meta.map( ( metaItem, index ) => ( + + { index > 0 && '· ' } + { metaItem } + + ) ) } + + ) } + +
+ ) ) } +
+ ); +} diff --git a/widgets/news/render.tsx b/widgets/news/render.tsx new file mode 100644 index 00000000000000..8f3100a79983b7 --- /dev/null +++ b/widgets/news/render.tsx @@ -0,0 +1,153 @@ +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; +import { dateI18n } from '@wordpress/date'; +import { __, _x } from '@wordpress/i18n'; +import { globe, postList, wordpress } from '@wordpress/icons'; +import { Spinner } from '@wordpress/components'; +/* eslint-disable @wordpress/use-recommended-components */ +import { Card, EmptyState, Icon, Link, Stack } from '@wordpress/ui'; +/* eslint-enable @wordpress/use-recommended-components */ + +/** + * Internal dependencies + */ +import List, { type ListItem } from './list'; + +interface NewsPost { + id: number; + title: { rendered: string }; + link: string; + date: string; +} + +interface NewsFeed { + key: string; + label: string; + siteUrl: string; + posts: NewsPost[]; +} + +const NEWS_FEEDS = [ + { + key: 'news', + label: __( 'WordPress Blog' ), + siteUrl: _x( + 'https://wordpress.org/news/', + 'Events and News dashboard widget' + ), + apiUrl: 'https://wordpress.org/news/wp-json/wp/v2/posts?per_page=3&_fields=id,title,link,date', + }, + { + key: 'planet', + label: __( 'Other WordPress News' ), + siteUrl: _x( + 'https://planet.wordpress.org/', + 'Events and News dashboard widget' + ), + apiUrl: 'https://planet.wordpress.org/wp-json/wp/v2/posts?per_page=3&_fields=id,title,link,date', + }, +]; + +function decodeEntities( html: string ): string { + const txt = document.createElement( 'textarea' ); + txt.innerHTML = html; + return txt.value; +} + +export default function WordPressNews() { + const [ newsFeeds, setNewsFeeds ] = useState< NewsFeed[] >( [] ); + const [ newsLoading, setNewsLoading ] = useState( true ); + + useEffect( () => { + Promise.all( + NEWS_FEEDS.map( async ( feed ) => { + try { + const posts: NewsPost[] = await fetch( feed.apiUrl ).then( + ( r ) => r.json() + ); + return { + key: feed.key, + label: feed.label, + siteUrl: feed.siteUrl, + posts, + }; + } catch { + return { + key: feed.key, + label: feed.label, + siteUrl: feed.siteUrl, + posts: [], + }; + } + } ) + ) + .then( setNewsFeeds ) + .finally( () => setNewsLoading( false ) ); + }, [] ); + + const combinedItems: ListItem[] = newsFeeds + .flatMap( ( feed ) => + feed.posts.map( ( post ) => ( { + feedKey: feed.key, + feedLabel: feed.label, + id: post.id, + title: decodeEntities( post.title.rendered ), + url: post.link, + date: post.date, + } ) ) + ) + .sort( + ( a, b ) => + new Date( b.date ).getTime() - new Date( a.date ).getTime() + ) + .map( ( post ) => ( { + id: `${ post.feedKey }-${ post.id }`, + title: post.title, + url: post.url, + icon: + post.feedKey === 'news' ? ( + + ) : ( + + ), + meta: [ + post.feedLabel, + dateI18n( __( 'M j, Y g:i a' ), post.date ), + ], + } ) ); + + const emptyState = ( + + + + + { __( 'Quiet for now — the next headline is on its way.' ) } + + + + ); + + return ( + + + { newsLoading && ( + + + + ) } + + + { __( 'See all' ) } + + + + ); +} diff --git a/widgets/news/style.module.css b/widgets/news/style.module.css new file mode 100644 index 00000000000000..7ee257aff6e127 --- /dev/null +++ b/widgets/news/style.module.css @@ -0,0 +1,30 @@ + +.footer { + padding: 12px 0 0; +} + +.listItem { + inline-size: 100%; +} + +.listItemIcon { + inline-size: 36px; + block-size: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: var(--wpds-color-bg-track-neutral-weak); + color: var(--wpds-color-fg-content-neutral-weak); + flex-shrink: 0; +} + +.listItemContent { + flex: 1; + min-inline-size: 0; +} + +.statusText { + color: var(--wpds-color-fg-content-neutral-weak); + margin: 0; +} diff --git a/widgets/news/widget.json b/widgets/news/widget.json new file mode 100644 index 00000000000000..7d2fe10062bd5e --- /dev/null +++ b/widgets/news/widget.json @@ -0,0 +1,6 @@ +{ + "name": "core/news", + "title": "WordPress news", + "description": "Displays the latest news from WordPress.org and Planet WordPress.", + "category": "dashboard" +} diff --git a/widgets/news/widget.ts b/widgets/news/widget.ts new file mode 100644 index 00000000000000..0acec4ef377d64 --- /dev/null +++ b/widgets/news/widget.ts @@ -0,0 +1,6 @@ +import { __ } from '@wordpress/i18n'; + +export default { + name: 'core/news', + title: __( 'WordPress news' ), +}; diff --git a/widgets/on-this-day/render.tsx b/widgets/on-this-day/render.tsx new file mode 100644 index 00000000000000..510dda5265c79f --- /dev/null +++ b/widgets/on-this-day/render.tsx @@ -0,0 +1,546 @@ +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import apiFetch from '@wordpress/api-fetch'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; +import { postList } from '@wordpress/icons'; + +// Dashboard is still experimental. +/* eslint-disable @wordpress/use-recommended-components -- `Notice` for load errors; tracked with WPDS adoption. */ +import { + Badge, + Card, + EmptyState, + Link, + Notice, + Stack, + Text, +} from '@wordpress/ui'; +/* eslint-enable @wordpress/use-recommended-components */ + +/** + * Internal dependencies + */ +import styles from './style.module.css'; + +type OnThisDayAttributes = { + windowDays?: number; +}; + +type WidgetProps = { + attributes?: OnThisDayAttributes; + setAttributes?: ( next: Partial< OnThisDayAttributes > ) => void; +}; + +type OnThisDayThumbnail = { + url: string; + width: number; + height: number; + srcset: string; + alt: string; +} | null; + +type OnThisDayCategory = { + id: number; + name: string; + slug: string; + link: string; +}; + +type OnThisDayPost = { + id: number; + title: string; + status: string; + excerpt: string; + time_iso: string; + time_display: string; + edit_url: string; + view_url: string; + categories: OnThisDayCategory[]; + thumbnail: OnThisDayThumbnail; +}; + +type OnThisDayYearGroup = { + year: number; + years_ago: number; + posts: OnThisDayPost[]; +}; + +type OnThisDayPayload = { + window_days: number; + window_label: string; + years: OnThisDayYearGroup[]; +}; + +const MIN_WINDOW = 1; +const MAX_WINDOW = 7; + +function clampWindowDays( value: unknown ): number { + const n = + typeof value === 'number' ? value : parseInt( String( value ), 10 ); + if ( Number.isNaN( n ) ) { + return MIN_WINDOW; + } + return Math.min( MAX_WINDOW, Math.max( MIN_WINDOW, n ) ); +} + +function yearsAgoLabel( yearsAgo: number ): string { + if ( yearsAgo === 1 ) { + return __( '1 yr ago' ); + } + return sprintf( + /* translators: %d: Number of years since the post was published. */ + __( '%d yrs ago' ), + yearsAgo + ); +} + +/** + * @param windowDays Number of days in the “on this day” window (1–7). + */ +function getRangeDayCountLabel( windowDays: number ): string { + return sprintf( + /* translators: %d: Number of days in the date range control. */ + _n( '%d day', '%d days', windowDays ), + windowDays + ); +} + +export default function OnThisDay( { + attributes, + setAttributes, +}: WidgetProps ) { + const windowDays = clampWindowDays( attributes?.windowDays ); + + const authorId = useSelect( + ( select ) => + select( coreStore ).getCurrentUser()?.id as number | undefined, + [] + ); + + const [ data, setData ] = useState< OnThisDayPayload | null >( null ); + const [ loadError, setLoadError ] = useState< Error | null >( null ); + const [ isLoading, setIsLoading ] = useState( false ); + + useEffect( () => { + if ( ! authorId ) { + setData( null ); + setLoadError( null ); + setIsLoading( false ); + return; + } + + let cancelled = false; + setIsLoading( true ); + setLoadError( null ); + + apiFetch< OnThisDayPayload >( { + path: addQueryArgs( '/wp/v2/on-this-day', { + window_days: windowDays, + } ), + } ) + .then( ( response ) => { + if ( ! cancelled ) { + setData( response ); + } + } ) + .catch( ( err: unknown ) => { + if ( ! cancelled ) { + setLoadError( + err instanceof Error ? err : new Error( String( err ) ) + ); + setData( null ); + } + } ) + .finally( () => { + if ( ! cancelled ) { + setIsLoading( false ); + } + } ); + + return () => { + cancelled = true; + }; + }, [ authorId, windowDays ] ); + + if ( ! authorId ) { + return ( + + + { __( 'Sign in to see your writing history.' ) } + + + ); + } + + if ( isLoading ) { + return ( + + + { __( 'Loading…' ) } + + + ); + } + + if ( loadError ) { + return ( + + + { __( + 'Could not load On This Day. Please try again in a moment.' + ) } + + + ); + } + + const windowLabel = data?.window_label ?? ''; + + const windowRangeField = setAttributes ? ( +
+ + { __( 'Range' ) } + + + setAttributes( { + windowDays: clampWindowDays( + ( event.target as HTMLInputElement ).value + ), + } ) + } + /> + + { getRangeDayCountLabel( windowDays ) } + +
+ ) : null; + + const isEmpty = + ! data || + ! data.years?.length || + data.years.every( ( y ) => y.posts.length === 0 ); + + if ( isEmpty ) { + return ( + + + { windowLabel ? ( + { windowLabel } + ) : null } + + + + + { __( 'Nothing on this day (yet)' ) } + + + { windowDays === 1 + ? sprintf( + /* translators: %s: A formatted calendar date. */ + __( + 'You have not published anything on %s in a previous year. Publish something today and check back next year!' + ), + windowLabel + ) + : sprintf( + /* translators: %s: A formatted date range. */ + __( + 'You have not published anything between %s in previous years. Write something today and check back next year!' + ), + windowLabel + ) } + + + { windowRangeField } + + ); + } + + if ( ! data ) { + return null; + } + + return ( + + + { windowLabel ? ( + { windowLabel } + ) : null } + + +
+ { data.years.map( ( group ) => ( +
+ } + > + { sprintf( + /* translators: 1: Year, 2: Human-readable “years ago”. */ + __( '%1$d · %2$s' ), + group.year, + yearsAgoLabel( group.years_ago ) + ) } + + + } + > + { group.posts.map( ( post ) => { + const isPrivate = post.status === 'private'; + const isDraft = post.status === 'draft'; + + return ( +
  • + + + + { post.thumbnail ? ( +
    + { +
    + ) : null } + + + + + } + style={ { + fontWeight: 600, + } } + > + { + post.title + } + + + + { isDraft ? ( + + { __( + 'Draft' + ) } + + ) : null } + { isPrivate ? ( + + { __( + 'Private' + ) } + + ) : null } + + + { post.excerpt ? ( + + { post.excerpt } + + ) : null } +
    + + { post.categories + .length ? ( + <> + + + { post.categories.map( + ( + cat, + i + ) => ( + + { i > + 0 ? ( + + { + ', ' + } + + ) : null } + { cat.link ? ( + + { + cat.name + } + + ) : ( + cat.name + ) } + + ) + ) } + + + ) : null } +
    +
    + + { __( 'Edit' ) } + + { post.view_url ? ( + + { __( + 'View' + ) } + + ) : null } +
    +
    +
    +
    +
    +
  • + ); + } ) } +
    +
    + ) ) } +
    + + { windowRangeField } +
    + ); +} diff --git a/widgets/on-this-day/style.module.css b/widgets/on-this-day/style.module.css new file mode 100644 index 00000000000000..9a4b7e75bc3456 --- /dev/null +++ b/widgets/on-this-day/style.module.css @@ -0,0 +1,74 @@ +.scroll { + max-height: 360px; + overflow: auto; +} + +.windowRow { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); +} + +.range { + width: 100%; + margin: 0; + accent-color: var(--wp-admin-theme-color, #3858e9); +} + +.yearHeading { + margin: 12px 0 4px; +} + +.postBlock { + padding: 8px 0 12px; +} + +.postMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 4px; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.thumbnail { + flex: 0 0 auto; + width: 40px; + height: 40px; + border-radius: 50%; + overflow: hidden; + border: 1px solid color-mix(in srgb, currentColor 12%, transparent); + background: color-mix(in srgb, currentColor 6%, transparent); +} + +.thumbnail img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.metaSep { + color: color-mix(in srgb, currentColor 35%, transparent); +} + +.categories { + min-width: 0; + color: color-mix(in srgb, currentColor 55%, transparent); + font-size: 12px; + line-height: 1.5; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/widgets/on-this-day/widget.json b/widgets/on-this-day/widget.json new file mode 100644 index 00000000000000..c24a2c3240ab36 --- /dev/null +++ b/widgets/on-this-day/widget.json @@ -0,0 +1,12 @@ +{ + "name": "core/on-this-day", + "title": "On This Day", + "description": "Shows posts you published on today’s month and day in previous years.", + "category": "dashboard", + "keywords": [ "history", "memories", "posts", "archive" ], + "example": { + "attributes": { + "windowDays": 1 + } + } +} diff --git a/widgets/on-this-day/widget.ts b/widgets/on-this-day/widget.ts new file mode 100644 index 00000000000000..102c0c549d0777 --- /dev/null +++ b/widgets/on-this-day/widget.ts @@ -0,0 +1,6 @@ +import { __ } from '@wordpress/i18n'; + +export default { + name: 'core/on-this-day', + title: __( 'On This Day' ), +}; diff --git a/widgets/quick-draft/render.tsx b/widgets/quick-draft/render.tsx new file mode 100644 index 00000000000000..f5f4f72f1296e3 --- /dev/null +++ b/widgets/quick-draft/render.tsx @@ -0,0 +1,295 @@ +/** + * WordPress dependencies + */ +import { useState, useCallback } from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { __, _x, sprintf } from '@wordpress/i18n'; +import { dateI18n } from '@wordpress/date'; +import { + SnackbarList, + TextareaControl, + TextControl, +} from '@wordpress/components'; +import { store as noticesStore } from '@wordpress/notices'; +import { MediaUpload, MediaUploadCheck } from '@wordpress/block-editor'; +import { postFeaturedImage } from '@wordpress/icons'; + +// Dashboard is still experimental. +/* eslint-disable @wordpress/use-recommended-components */ +import { Button, Card, Icon, Link, Text, Stack, Tooltip } from '@wordpress/ui'; +/* eslint-enable @wordpress/use-recommended-components */ +import type { Post } from '@wordpress/core-data'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type DraftPost = Post & { + title: { rendered: string; raw?: string }; + content: { rendered: string; raw?: string }; +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Returns the first N words of a plain-text string, mirroring PHP's + * `wp_trim_words()` with the `draft_length` locale value (default 10). + * + * @param {string} text Input text. + * @param {number} maxWords Maximum number of words to keep. + */ +function trimWords( text: string, maxWords: number ): string { + const words = text + .replace( /<[^>]+>/g, ' ' ) + .trim() + .split( /\s+/ ) + .filter( Boolean ); + + if ( words.length <= maxWords ) { + return words.join( ' ' ); + } + + return words.slice( 0, maxWords ).join( ' ' ) + '\u2026'; +} + +// ─── Recent Drafts ──────────────────────────────────────────────────────────── + +/** + * @param {{ id: number }} props + */ +function DraftItem( { post }: { post: DraftPost } ) { + const title = + ( post.title as { rendered: string } )?.rendered || + /* translators: Placeholder for a draft post with no title. */ + __( '(no title)' ); + + const rawContent = + ( post.content as { raw?: string; rendered: string } )?.raw ?? + ( post.content as { rendered: string } )?.rendered ?? + ''; + + /* translators: Maximum number of words used in a draft preview on the dashboard. */ + const draftLength = parseInt( _x( '10', 'draft_length' ), 10 ) || 10; + const excerpt = trimWords( rawContent, draftLength ); + + const editUrl = `post.php?post=${ post.id }&action=edit`; + + /* translators: Date format for draft timestamps on the dashboard, see https://www.php.net/manual/datetime.format.php */ + const formattedDate = dateI18n( + __( 'F j, Y' ), + ( post.modified as string ) ?? '' + ); + + return ( +
  • +
    + + { title } + + +
    + { excerpt &&

    { excerpt }

    } +
  • + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function QuickDraft() { + const [ title, setTitle ] = useState( '' ); + const [ content, setContent ] = useState( '' ); + const [ isSaving, setIsSaving ] = useState( false ); + const [ featuredImageId, setFeaturedImageId ] = useState< number | null >( + null + ); + + const currentUser = useSelect( + ( select ) => select( coreStore ).getCurrentUser(), + [] + ); + + const drafts = useSelect( + ( select ) => { + if ( ! currentUser?.id ) { + return undefined; + } + + return select( coreStore ).getEntityRecords< DraftPost >( + 'postType', + 'post', + { + status: 'draft', + author: currentUser.id, + orderby: 'modified', + order: 'desc', + per_page: 3, + context: 'edit', + } + ); + }, + [ currentUser?.id ] + ); + + const { saveEntityRecord, invalidateResolution } = useDispatch( coreStore ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + const notices = useSelect( + ( select ) => select( noticesStore ).getNotices(), + [] + ); + const { removeNotice } = useDispatch( noticesStore ); + const snackbarNotices = notices.filter( + ( { type } ) => type === 'snackbar' + ); + + const handleSave = useCallback( async () => { + if ( ! title.trim() ) { + return; + } + + setIsSaving( true ); + + try { + await saveEntityRecord( 'postType', 'post', { + title, + content, + status: 'draft', + ...( featuredImageId !== null && { + featured_media: featuredImageId, + } ), + } ); + + // Bust the drafts cache so the new post appears in the list. + invalidateResolution( 'getEntityRecords', [ + 'postType', + 'post', + { + status: 'draft', + author: currentUser?.id, + orderby: 'modified', + order: 'desc', + per_page: 3, + context: 'edit', + }, + ] ); + + setTitle( '' ); + setContent( '' ); + setFeaturedImageId( null ); + createSuccessNotice( __( 'Draft saved.' ), { type: 'snackbar' } ); + } catch { + createErrorNotice( __( 'An error occurred. Please try again.' ), { + type: 'snackbar', + } ); + } finally { + setIsSaving( false ); + } + }, [ + title, + content, + featuredImageId, + currentUser?.id, + saveEntityRecord, + invalidateResolution, + createSuccessNotice, + createErrorNotice, + ] ); + + const hasDrafts = !! drafts && drafts.length > 0; + + return ( + + + removeNotice( id ) } + /> + } + > + + + + + + { + setFeaturedImageId( media.id ); + } } + allowedTypes={ [ 'image' ] } + value={ featuredImageId ?? undefined } + render={ ( { open } ) => ( + + + + + + { __( 'Add featured image' ) } + + + ) } + /> + + + + + + { hasDrafts && ( +
    + }> + { __( 'Your recent drafts' ) } + +

    + + { __( 'View all drafts' ) } + +

    +
      + { drafts.map( ( post ) => ( + + ) ) } +
    +
    + ) } +
    +
    + ); +} diff --git a/widgets/quick-draft/widget.json b/widgets/quick-draft/widget.json new file mode 100644 index 00000000000000..fa583310d7eec2 --- /dev/null +++ b/widgets/quick-draft/widget.json @@ -0,0 +1,6 @@ +{ + "name": "core/quick-draft", + "title": "Quick draft", + "description": "Quickly draft a new post and see your recent drafts.", + "category": "publishing" +} diff --git a/widgets/quick-draft/widget.ts b/widgets/quick-draft/widget.ts new file mode 100644 index 00000000000000..c11bba98e2df6d --- /dev/null +++ b/widgets/quick-draft/widget.ts @@ -0,0 +1,6 @@ +import { __ } from '@wordpress/i18n'; + +export default { + name: 'core/quick-draft', + title: __( 'Quick Draft' ), +}; diff --git a/widgets/site-health/render.tsx b/widgets/site-health/render.tsx new file mode 100644 index 00000000000000..dfcb9ba23215b3 --- /dev/null +++ b/widgets/site-health/render.tsx @@ -0,0 +1,231 @@ +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; +import { Spinner } from '@wordpress/components'; +import { Card, Link, Stack, Text } from '@wordpress/ui'; + +/** + * Internal dependencies + */ +import styles from './style.module.css'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type TestStatus = 'good' | 'recommended' | 'critical'; + +type TestResult = { + status: TestStatus; +}; + +type IssueCounts = { + good: number; + recommended: number; + critical: number; +}; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +// Async site health tests exposed via the REST API. +const ASYNC_TEST_PATHS = [ + '/wp-site-health/v1/tests/background-updates', + '/wp-site-health/v1/tests/loopback-requests', + '/wp-site-health/v1/tests/https-status', + '/wp-site-health/v1/tests/dotorg-communication', + '/wp-site-health/v1/tests/authorization-header', +] as const; + +// SVG circle geometry (matches WP core's site-health progress ring). +const RADIUS = 90; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; // ≈ 565.48 + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +type HealthTone = 'success' | 'warning' | 'error'; + +/** + * Maps a percentage to a semantic tone used for CSS class selection. + * + * @param {number} pct Percentage of passing tests (0–100). + */ +function toneForPercentage( pct: number ): HealthTone { + if ( pct === 100 ) { + return 'success'; + } + if ( pct >= 75 ) { + return 'warning'; + } + return 'error'; +} + +/** + * Returns the human-readable status message matching the PHP widget logic. + * + * @param {IssueCounts} counts Aggregated issue counts. + */ +function statusMessage( counts: IssueCounts ): string { + const total = counts.recommended + counts.critical; + + if ( total <= 0 ) { + return __( + 'Great job! Your site currently passes all site health checks.' + ); + } + + if ( counts.critical === 1 ) { + return __( + 'Your site has a critical issue that should be addressed as soon as possible to improve its performance and security.' + ); + } + + if ( counts.critical > 1 ) { + return __( + 'Your site has critical issues that should be addressed as soon as possible to improve its performance and security.' + ); + } + + if ( counts.recommended === 1 ) { + return __( + 'Your site\u2019s health is looking good, but there is still one thing you can do to improve its performance and security.' + ); + } + + return __( + 'Your site\u2019s health is looking good, but there are still some things you can do to improve its performance and security.' + ); +} + +// ─── Circle progress ────────────────────────────────────────────────────────── + +/** + * @param {{ percentage: number, tone: HealthTone }} props + */ +function CircleProgress( { + percentage, + tone, +}: { + percentage: number; + tone: HealthTone; +} ) { + const offset = CIRCUMFERENCE * ( 1 - percentage / 100 ); + + return ( +
    + + + + + { percentage }% + + +
    + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function SiteHealth() { + const [ counts, setCounts ] = useState< IssueCounts | null >( null ); + const [ isLoading, setIsLoading ] = useState( true ); + + useEffect( () => { + const requests = ASYNC_TEST_PATHS.map( ( path ) => + apiFetch< TestResult >( { path } ).catch( () => null ) + ); + + Promise.all( requests ).then( ( results ) => { + const totals: IssueCounts = { + good: 0, + recommended: 0, + critical: 0, + }; + + for ( const result of results ) { + if ( result?.status && result.status in totals ) { + totals[ result.status ]++; + } + } + + setCounts( totals ); + setIsLoading( false ); + } ); + }, [] ); + + if ( isLoading ) { + return ( + + + + ); + } + + if ( ! counts ) { + return null; + } + + const total = counts.good + counts.recommended + counts.critical; + const percentage = + total > 0 ? Math.round( ( counts.good / total ) * 100 ) : 0; + const issuesTotal = counts.recommended + counts.critical; + const tone = toneForPercentage( percentage ); + + return ( + + + + { statusMessage( counts ) } + { issuesTotal > 0 && ( + + { sprintf( + /* translators: %d: Number of issues to address. */ + _n( + 'Review %d item', + 'Review %d items', + issuesTotal + ), + issuesTotal + ) } + + ) } + + + ); +} diff --git a/widgets/site-health/style.module.css b/widgets/site-health/style.module.css new file mode 100644 index 00000000000000..8340ba1b44259c --- /dev/null +++ b/widgets/site-health/style.module.css @@ -0,0 +1,42 @@ + +.circle.is-success { + --site-health-stroke: var(--wpds-color-stroke-surface-success); + --site-health-fg: var(--wpds-color-fg-content-success); +} + +.circle.is-warning { + --site-health-stroke: var(--wpds-color-stroke-surface-warning); + --site-health-fg: var(--wpds-color-fg-content-warning); +} + +.circle.is-error { + --site-health-stroke: var(--wpds-color-stroke-surface-error); + --site-health-fg: var(--wpds-color-fg-content-error); +} + +.circle { + flex-shrink: 0; + width: 68px; + height: 68px; +} + +.circle svg circle:first-child { + stroke: var(--wpds-color-bg-track-neutral); + stroke-width: 12; +} + +.circle svg circle:last-of-type { + stroke: var(--site-health-stroke); + stroke-width: 12; + stroke-linecap: round; + transform: rotate(-90deg); + transform-origin: 50% 50%; + transition: stroke-dashoffset 0.5s ease; +} + +.percentage { + fill: var(--site-health-fg); + font-size: 36px; + font-weight: 600; +} + diff --git a/widgets/site-health/widget.json b/widgets/site-health/widget.json new file mode 100644 index 00000000000000..b1744ba6cf7fbf --- /dev/null +++ b/widgets/site-health/widget.json @@ -0,0 +1,6 @@ +{ + "name": "core/site-health", + "title": "Site health", + "description": "Shows an overview of your site health and any issues that need attention.", + "category": "site" +} diff --git a/widgets/site-health/widget.ts b/widgets/site-health/widget.ts new file mode 100644 index 00000000000000..9b049f2633a017 --- /dev/null +++ b/widgets/site-health/widget.ts @@ -0,0 +1,6 @@ +import { __ } from '@wordpress/i18n'; + +export default { + name: 'core/site-health', + title: __( 'Site Health Status' ), +}; diff --git a/widgets/site-preview/render.tsx b/widgets/site-preview/render.tsx new file mode 100644 index 00000000000000..415f1796487b26 --- /dev/null +++ b/widgets/site-preview/render.tsx @@ -0,0 +1,84 @@ +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { __ } from '@wordpress/i18n'; +import { Spinner } from '@wordpress/components'; + +// Dashboard is still experimental. +// eslint-disable-next-line @wordpress/use-recommended-components +import { Button, Stack } from '@wordpress/ui'; +import styles from './style.module.css'; + +export default function SitePreview() { + const [ isIframeLoading, setIsIframeLoading ] = useState( true ); + const siteUrl = useSelect( + ( select ) => + select( coreStore ).getEntityRecord< { url: string } >( + 'root', + 'site', + undefined + )?.url, + [] + ); + + const isBlockTheme = useSelect( + ( select ) => + !! ( select( coreStore ) as any ).getCurrentTheme()?.is_block_theme, + [] + ); + + useEffect( () => { + setIsIframeLoading( true ); + }, [ siteUrl ] ); + + if ( ! siteUrl ) { + return null; + } + + const src = `${ siteUrl }/?hide_banners=true&preview_overlay=true&preview=true`; + const editUrl = isBlockTheme ? 'site-editor.php' : 'customize.php'; + + return ( +
    +
    + { isIframeLoading && ( + + + + ) } + + + + +
    +
    + ); +} diff --git a/widgets/site-preview/style.module.css b/widgets/site-preview/style.module.css new file mode 100644 index 00000000000000..ce564700a57c58 --- /dev/null +++ b/widgets/site-preview/style.module.css @@ -0,0 +1,44 @@ +.container { + height: 300px; + overflow: hidden; +} + +.previewWrap { + position: relative; + height: 100%; +} + +.iframe { + position: absolute; + inset-block-start: 0; + inset-inline-start: 50%; + width: 250%; + min-height: 375%; + transform: scale(0.4); + transform-origin: center top; + translate: -50% -13px; +} + +.overlay { + position: absolute; + inset: 0; + opacity: 0; +} + +.previewWrap:hover .overlay { + opacity: 1; +} + +@media (prefers-reduced-motion: no-preference) { + .iframe { + transition: transform 0.2s ease-in; + } + + .previewWrap:hover .iframe { + transform: scale(0.42); + } + + .overlay { + transition: opacity 0.1s ease-in; + } +} diff --git a/widgets/site-preview/widget.json b/widgets/site-preview/widget.json new file mode 100644 index 00000000000000..c39bd313cd3427 --- /dev/null +++ b/widgets/site-preview/widget.json @@ -0,0 +1,6 @@ +{ + "name": "core/site-preview", + "title": "Site Preview", + "description": "Shows a scaled preview of the site homepage.", + "category": "site" +} diff --git a/widgets/site-preview/widget.ts b/widgets/site-preview/widget.ts new file mode 100644 index 00000000000000..549b6daf84177c --- /dev/null +++ b/widgets/site-preview/widget.ts @@ -0,0 +1,6 @@ +import { __ } from '@wordpress/i18n'; + +export default { + name: 'core/site-preview', + title: __( 'Site Preview' ), +}; diff --git a/widgets/welcome/dashboard-background.svg b/widgets/welcome/dashboard-background.svg new file mode 100644 index 00000000000000..e4d70862d7ff48 --- /dev/null +++ b/widgets/welcome/dashboard-background.svg @@ -0,0 +1,63 @@ + diff --git a/widgets/welcome/render.tsx b/widgets/welcome/render.tsx new file mode 100644 index 00000000000000..12f8928e480d0e --- /dev/null +++ b/widgets/welcome/render.tsx @@ -0,0 +1,469 @@ +/** + * WordPress dependencies + */ +import { useId, useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { __, sprintf } from '@wordpress/i18n'; +import { close } from '@wordpress/icons'; +import { + type ThemeProvider as ThemeProviderType, + privateApis as themePrivateApis, +} from '@wordpress/theme'; +// Dashboard is still experimental. +// eslint-disable-next-line @wordpress/use-recommended-components +import { Card, IconButton, Link, Stack, Text } from '@wordpress/ui'; + +/** + * Internal dependencies + */ +import { unlock } from '../lock-unlock'; +import styles from './style.module.css'; + +declare global { + interface Window { + wpDashboardWidgetsConfig?: { + wpVersion?: string; + }; + } +} + +const ThemeProvider: typeof ThemeProviderType = + unlock( themePrivateApis ).ThemeProvider; + +// ─── Icons ──────────────────────────────────────────────────────────────────── + +function IconBlocks() { + return ( + + ); +} + +function IconLayout() { + return ( + + ); +} + +function IconStyles() { + return ( + + ); +} + +// ─── Background SVG ─────────────────────────────────────────────────────────── + +function DashboardBackground() { + const uid = useId(); + const ids = { + clip: `${ uid }-clip`, + mask: `${ uid }-mask`, + line1: `${ uid }-line-1`, + line2: `${ uid }-line-2`, + line3: `${ uid }-line-3`, + line4: `${ uid }-line-4`, + line5: `${ uid }-line-5`, + radial1: `${ uid }-radial-1`, + radial2: `${ uid }-radial-2`, + }; + + return ( + + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function Welcome() { + const displayVersion = window.wpDashboardWidgetsConfig?.wpVersion ?? ''; + const currentTheme = useSelect( + ( select ) => ( select( coreStore ) as any ).getCurrentTheme(), + [] + ); + + const canCustomize = useSelect( + ( select ) => + !! ( select( coreStore ).getCurrentUser() as any )?.capabilities + ?.customize, + [] + ); + + const isBlockTheme = !! currentTheme?.is_block_theme; + const [ isDismissed, setIsDismissed ] = useState( false ); + + if ( isDismissed ) { + return null; + } + + return ( + <> + +
    + +
    + setIsDismissed( true ) } + /> + }> + { __( 'Welcome to WordPress!' ) } + + { displayVersion && ( + }> + + { sprintf( + /* translators: %s: Current WordPress version. */ + __( + 'Learn more about the %s version.' + ), + displayVersion + ) } + + + ) } +
    +
    +
    + + + + + + + }> + { __( + 'Author rich content with blocks and patterns' + ) } + + }> + { __( + 'Block patterns are pre-configured block layouts. Use them to get inspired or create new pages in a flash.' + ) } + + + + { __( 'Add a new page' ) } + + + + + + + + + { isBlockTheme ? ( + <> + } + > + { __( + 'Customize your entire site with block themes' + ) } + + }> + { __( + 'Design everything on your site \u2014 from the header down to the footer, all using blocks and patterns.' + ) } + + + + { __( 'Open site editor' ) } + + + + ) : ( + <> + } + > + { __( 'Start Customizing' ) } + + }> + { __( + 'Configure your site\u2019s logo, header, menus, and more in the Customizer.' + ) } + + { canCustomize && ( + + + { __( 'Open the Customizer' ) } + + + ) } + + ) } + + + + + + + { isBlockTheme ? ( + <> + } + > + { __( + 'Switch up your site\u2019s look & feel with Styles' + ) } + + }> + { __( + 'Tweak your site, or give it a whole new look! Get creative \u2014 how about a new color palette or font?' + ) } + + + + { __( 'Edit styles' ) } + + + + ) : ( + <> + } + > + { __( + 'Discover a new way to build your site.' + ) } + + }> + { __( + 'There is a new kind of WordPress theme, called a block theme, that lets you build the site you\u2019ve always wanted \u2014 with blocks and styles.' + ) } + + + + { __( 'Learn about block themes' ) } + + + + ) } + + + + + + ); +} diff --git a/widgets/welcome/style.module.css b/widgets/welcome/style.module.css new file mode 100644 index 00000000000000..8a34201b8512f4 --- /dev/null +++ b/widgets/welcome/style.module.css @@ -0,0 +1,73 @@ +.headerWrap { + position: relative; + overflow: hidden; + min-height: 120px; +} + +.headerImage { + position: absolute; + inset: 0; + pointer-events: none; +} + +.headerImage svg { + width: 100%; + height: 100%; +} + +.header { + padding: 48px 48px 80px; + position: relative; + z-index: 1; +} + +.dismissButton { + position: absolute; + inset-block-start: 12px; + inset-inline-end: 12px; +} + +@media (max-width: 782px) { + .columns { + flex-direction: column; + } +} + +.column { + flex: 1; + padding-inline-end: 20px; +} + +.column:last-child { + padding-inline-end: 0; +} + +.column + .column { + padding-inline-start: 20px; + border-inline-start: 1px solid var(--wpds-color-stroke-surface-neutral); +} + +@media (max-width: 782px) { + .column { + padding-block-end: 20px; + padding-inline-end: 0; + } + + .column:last-child { + padding-block-end: 0; + } + + .column + .column { + padding-inline-start: 0; + padding-block-start: 20px; + border-inline-start: none; + border-block-start: 1px solid var(--wpds-color-stroke-surface-neutral); + } +} + +.column svg { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: var(--wpds-border-radius-sm); +} diff --git a/widgets/welcome/widget.json b/widgets/welcome/widget.json new file mode 100644 index 00000000000000..a90f5af4bd81d7 --- /dev/null +++ b/widgets/welcome/widget.json @@ -0,0 +1,6 @@ +{ + "name": "core/welcome", + "title": "Welcome", + "description": "Displays an introduction to WordPress with links to key admin areas.", + "category": "dashboard" +} diff --git a/widgets/welcome/widget.ts b/widgets/welcome/widget.ts new file mode 100644 index 00000000000000..427f6f2074f7df --- /dev/null +++ b/widgets/welcome/widget.ts @@ -0,0 +1,6 @@ +import { __ } from '@wordpress/i18n'; + +export default { + name: 'core/welcome', + title: __( 'Welcome' ), +};