diff --git a/lib/experimental/on-this-day/on-this-day.php b/lib/experimental/on-this-day/on-this-day.php new file mode 100644 index 00000000000000..3d7c5f2827d70a --- /dev/null +++ b/lib/experimental/on-this-day/on-this-day.php @@ -0,0 +1,73 @@ + __( 'Limit response to posts published on the given month and day (MM-DD), regardless of year.', 'gutenberg' ), + 'type' => 'string', + 'pattern' => GUTENBERG_ON_THIS_DAY_PATTERN, + ); + + return $params; +} +add_filter( 'rest_post_collection_params', 'gutenberg_on_this_day_register_collection_param' ); + +/** + * Translates the `on_this_day` REST parameter into a `date_query` clause. + * + * Skips the translation when the parameter is missing or fails the schema + * pattern. The schema-level validation in `rest_post_collection_params` + * normally catches invalid input before this filter runs, but the + * defensive check keeps the filter safe for callers that bypass the + * controller's argument validation. + * + * @param array $args `WP_Query` arguments built by the controller. + * @param WP_REST_Request $request REST request being handled. + * @return array `WP_Query` arguments, extended with a `date_query` clause when applicable. + */ +function gutenberg_on_this_day_apply_query( $args, $request ) { + $value = $request['on_this_day'] ?? null; + + if ( ! is_string( $value ) || ! preg_match( '/' . GUTENBERG_ON_THIS_DAY_PATTERN . '/', $value ) ) { + return $args; + } + + [ $month, $day ] = array_map( 'intval', explode( '-', $value ) ); + + if ( ! isset( $args['date_query'] ) || ! is_array( $args['date_query'] ) ) { + $args['date_query'] = array(); + } + + $args['date_query'][] = array( + 'month' => $month, + 'day' => $day, + ); + + return $args; +} + +add_filter( 'rest_post_query', 'gutenberg_on_this_day_apply_query', 10, 2 ); diff --git a/lib/load.php b/lib/load.php index b9a24113a33e78..723d68bfda069f 100644 --- a/lib/load.php +++ b/lib/load.php @@ -240,4 +240,5 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/dashboard-widgets/widget-types.php'; require __DIR__ . '/experimental/dashboard-widgets/dashboard-layout.php'; require __DIR__ . '/experimental/dashboard-widgets/default-layout-seed.php'; + require __DIR__ . '/experimental/on-this-day/on-this-day.php'; } diff --git a/phpunit/experimental/on-this-day/on-this-day-test.php b/phpunit/experimental/on-this-day/on-this-day-test.php new file mode 100644 index 00000000000000..e06ef141e5858d --- /dev/null +++ b/phpunit/experimental/on-this-day/on-this-day-test.php @@ -0,0 +1,158 @@ + + */ + protected static $post_ids = array(); + + public static function wpSetUpBeforeClass( $factory ) { + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + + self::$post_ids['oldest_may_11'] = $factory->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2018-05-11 10:00:00', + 'post_title' => 'Oldest May 11', + ) + ); + self::$post_ids['newer_may_11'] = $factory->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2020-05-11 10:00:00', + 'post_title' => 'Newer May 11', + ) + ); + self::$post_ids['other_day'] = $factory->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2019-07-04 10:00:00', + 'post_title' => 'Independence Day', + ) + ); + } + + public function set_up() { + parent::set_up(); + wp_set_current_user( self::$subscriber_id ); + } + + public function tear_down() { + wp_set_current_user( 0 ); + parent::tear_down(); + } + + public function test_collection_param_is_advertised_in_schema() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( self::ROUTE, $routes ); + + $get_route_args = null; + foreach ( $routes[ self::ROUTE ] as $route_handler ) { + if ( in_array( 'GET', (array) $route_handler['methods'], true ) || ! empty( $route_handler['methods']['GET'] ) ) { + $get_route_args = $route_handler['args']; + break; + } + } + + $this->assertNotNull( $get_route_args ); + $this->assertArrayHasKey( 'on_this_day', $get_route_args ); + $this->assertSame( 'string', $get_route_args['on_this_day']['type'] ); + $this->assertSame( GUTENBERG_ON_THIS_DAY_PATTERN, $get_route_args['on_this_day']['pattern'] ); + } + + public function test_filter_returns_only_posts_matching_month_day() { + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_param( 'on_this_day', '05-11' ); + $request->set_param( 'orderby', 'date' ); + $request->set_param( 'order', 'asc' ); + + $response = rest_do_request( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $ids = array_column( $response->get_data(), 'id' ); + sort( $ids ); + + $expected = array( self::$post_ids['oldest_may_11'], self::$post_ids['newer_may_11'] ); + sort( $expected ); + + $this->assertSame( $expected, $ids ); + } + + public function test_orderby_ascending_returns_oldest_match_first() { + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_param( 'on_this_day', '05-11' ); + $request->set_param( 'orderby', 'date' ); + $request->set_param( 'order', 'asc' ); + $request->set_param( 'per_page', 1 ); + + $response = rest_do_request( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 1, $data ); + $this->assertSame( self::$post_ids['oldest_may_11'], $data[0]['id'] ); + } + + public function test_no_match_returns_empty_collection() { + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_param( 'on_this_day', '02-29' ); + + $response = rest_do_request( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array(), $response->get_data() ); + } + + public function test_missing_param_returns_all_published_posts() { + $request = new WP_REST_Request( 'GET', self::ROUTE ); + + $response = rest_do_request( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $ids = array_column( $response->get_data(), 'id' ); + $this->assertContains( self::$post_ids['oldest_may_11'], $ids ); + $this->assertContains( self::$post_ids['newer_may_11'], $ids ); + $this->assertContains( self::$post_ids['other_day'], $ids ); + } + + public function test_invalid_format_is_rejected_with_400() { + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_param( 'on_this_day', '2026-05-11' ); + + $response = rest_do_request( $request ); + + $this->assertSame( 400, $response->get_status() ); + } + + public function test_query_filter_ignores_invalid_value_when_called_directly() { + $args = array( 'post_type' => 'post' ); + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_param( 'on_this_day', 'not-a-date' ); + + $filtered = gutenberg_on_this_day_apply_query( $args, $request ); + + $this->assertSame( $args, $filtered ); + } +} diff --git a/tsconfig.json b/tsconfig.json index dba162ff1cee44..b8edb76a36f853 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -93,7 +93,8 @@ { "path": "storybook" }, { "path": "test/e2e" }, { "path": "test/storybook-playwright" }, - { "path": "test/performance" } + { "path": "test/performance" }, + { "path": "widgets" } ], "files": [] } diff --git a/widgets/on-this-day/components/on-this-day-view/index.ts b/widgets/on-this-day/components/on-this-day-view/index.ts new file mode 100644 index 00000000000000..e120b96f51a1c0 --- /dev/null +++ b/widgets/on-this-day/components/on-this-day-view/index.ts @@ -0,0 +1 @@ +export { OnThisDayView, type BackgroundEffect } from './on-this-day-view'; diff --git a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx new file mode 100644 index 00000000000000..85d0e1473c5d1e --- /dev/null +++ b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx @@ -0,0 +1,155 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ +import { decodeEntities } from '@wordpress/html-entities'; +import { humanTimeDiff } from '@wordpress/date'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { calendar } from '@wordpress/icons'; +// eslint-disable-next-line @wordpress/use-recommended-components +import { EmptyState, Link, Stack, Text, Button } from '@wordpress/ui'; +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import type { UseOnThisDayPostResult } from '../../hooks/use-on-this-day-post'; +import styles from './style.module.css'; + +/** + * Named background filter presets. The widget exposes the chosen preset + * as an attribute; the surface picks the preset and the view resolves it + * to a CSS filter expression. + */ +export type BackgroundEffect = + | 'vintage' + | 'dim-saturate' + | 'dim-blur' + | 'grayscale' + | 'none'; + +export const BACKGROUND_EFFECTS: Record< BackgroundEffect, string > = { + vintage: 'sepia(0.3) brightness(0.55) saturate(0.9)', + 'dim-saturate': 'brightness(0.55) saturate(1.15)', + 'dim-blur': 'brightness(0.5) blur(2px)', + grayscale: 'grayscale(0.4) brightness(0.55)', + none: 'none', +}; + +/** + * View props: result of the data hook plus the background filter preset. + */ +export interface OnThisDayViewProps extends UseOnThisDayPostResult { + effect?: BackgroundEffect; +} + +/** + * Presentation for the `On This Day` widget. Decouples chrome and data + * fetching from the layout so Storybook and tests can drive each branch + * directly. + * + * @param {OnThisDayViewProps} props - The props for the `On This Day` widget. + * @return {React.ReactNode} - The `On This Day` widget. + */ +export function OnThisDayView( { + post, + isResolving, + hasAnyPosts, + effect = 'dim-blur', +}: OnThisDayViewProps ): React.ReactNode { + if ( isResolving ) { + return ( + + { __( 'Loading…' ) } + + ); + } + + if ( ! post ) { + return ( + + + + { __( 'Nothing on this day yet' ) } + + + { __( 'Your blogging memories are still being made.' ) } +
+ { __( 'Check back again soon.' ) } +
+ + { ! hasAnyPosts && ( + + + + ) } +
+ ); + } + + const hasImage = !! post.featuredImageUrl; + const filterValue = BACKGROUND_EFFECTS[ effect ] ?? BACKGROUND_EFFECTS.none; + const relativeTime = sprintf( + /* translators: %s: Human-readable time difference, e.g. "2 days ago". */ + __( 'On this day, %s' ), + humanTimeDiff( post.date ) + ); + const containerStyle = hasImage + ? ( { + '--on-this-day-bg-image': `url(${ post.featuredImageUrl })`, + '--on-this-day-bg-filter': filterValue, + } as React.CSSProperties ) + : undefined; + + return ( + + + { relativeTime } + + + { decodeEntities( post.title.rendered ) } + + + { post.commentCount > 0 && ( + + + { sprintf( + /* translators: %d: number of comments */ + _n( + '%d comment', + '%d comments', + post.commentCount + ), + post.commentCount + ) } + + + ) } + + + ); +} diff --git a/widgets/on-this-day/components/on-this-day-view/style.module.css b/widgets/on-this-day/components/on-this-day-view/style.module.css new file mode 100644 index 00000000000000..54f1cbee7f065a --- /dev/null +++ b/widgets/on-this-day/components/on-this-day-view/style.module.css @@ -0,0 +1,70 @@ +.container { + position: relative; + overflow: hidden; + isolation: isolate; + min-height: 240px; + height: 100%; +} + +.no-posts-today { + height: 100%; + justify-content: center; + align-items: center; + max-width: 100%; +} + +/* wp-admin's global `a` styles sit outside CSS layers and would beat + * the Button's layered rules. Restore them at higher specificity. */ +.no-posts-today a:any-link { + color: var(--wp-ui-button-foreground-color); + text-decoration: none; +} + +.no-posts-today a:any-link:hover, +.no-posts-today a:any-link:focus, +.no-posts-today a:any-link:active { + color: var(--wp-ui-button-foreground-color-active); + text-decoration: none; +} + + +.header { + padding: var(--wpds-dimension-padding-lg); + border-start-start-radius: var(--wpds-border-radius-lg); + border-start-end-radius: var(--wpds-border-radius-lg); +} + +/* Glass card: backdrop-filter dims only the pixels behind the text, + * so contrast holds on any image. Foreground uses + * `fg-interactive-neutral-strong` because the design system has no + * `fg-content-neutral-strong` variant. */ +.with-image .header { + --_gcd-heading-color: inherit; + + align-self: flex-start; + margin: var(--wpds-dimension-padding-md); + padding: var(--wpds-dimension-padding-md) var(--wpds-dimension-padding-lg); + border-radius: var(--wpds-border-radius-md); + color: var(--wpds-color-fg-interactive-neutral-strong); + background: color-mix(in srgb, var(--wpds-color-bg-surface-neutral-strong) 25%, transparent); + backdrop-filter: blur(3px) brightness(0.55) saturate(0.25); + -webkit-backdrop-filter: blur(10px) brightness(0.55) saturate(0.65); + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3); +} + +.with-image::before { + content: ""; + position: absolute; + inset: 0; + z-index: -2; + background-image: var(--on-this-day-bg-image); + background-position: center; + background-size: cover; + filter: var(--on-this-day-bg-filter, none); + transition: filter var(--wpds-motion-duration-lg) var(--wpds-motion-easing-expressive); +} + +.with-image:hover::before, +.with-image:focus-within::before { + filter: none; +} diff --git a/widgets/on-this-day/hooks/use-on-this-day-post/index.ts b/widgets/on-this-day/hooks/use-on-this-day-post/index.ts new file mode 100644 index 00000000000000..267f539563a148 --- /dev/null +++ b/widgets/on-this-day/hooks/use-on-this-day-post/index.ts @@ -0,0 +1,7 @@ +export { default as useOnThisDayPost } from './use-on-this-day-post'; +export type { + OnThisDayPost, + UseOnThisDayPostOptions, + UseOnThisDayPostResult, + TimeRange, +} from './use-on-this-day-post'; diff --git a/widgets/on-this-day/hooks/use-on-this-day-post/use-on-this-day-post.ts b/widgets/on-this-day/hooks/use-on-this-day-post/use-on-this-day-post.ts new file mode 100644 index 00000000000000..b5bf013b96a221 --- /dev/null +++ b/widgets/on-this-day/hooks/use-on-this-day-post/use-on-this-day-post.ts @@ -0,0 +1,275 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * Named time-range presets selectable via the widget's `timeRange` + * attribute. `custom` requires a companion `customDate` (`YYYY-MM-DD`). + */ +export type TimeRange = + | 'one-year-ago' + | 'five-years-ago' + | 'ten-years-ago' + | 'oldest-on-this-day' + | 'custom'; + +/** + * Subset of the WordPress post REST shape consumed by the widget, + * normalized so the view does not need to know that the featured image + * URL comes from a separate `attachment` entity. + */ +export interface OnThisDayPost { + id: number; + date: string; + title: { rendered: string }; + excerpt: { rendered: string }; + link: string; + commentCount: number; + featuredImageUrl: string | null; +} + +/** + * Raw post record returned by the posts collection. `featured_media` is + * the attachment id; the URL is resolved with a second `getEntityRecord` + * call against the `attachment` entity. + */ +interface RawPost { + id: number; + date: string; + title: { rendered: string }; + excerpt: { rendered: string }; + link: string; + featured_media?: number; + comment_count?: string | number; +} + +/** + * Minimal subset of an `attachment` REST record consumed by the hook. + */ +interface AttachmentRecord { + id: number; + source_url?: string; +} + +/** + * Return value of {@link useOnThisDayPost}. + */ +export interface UseOnThisDayPostResult { + /** + * The matching post resolved by the selected time range, or `null` + * when no post matches or the request has not resolved yet. + */ + post: OnThisDayPost | null; + + /** + * `true` until the underlying `getEntityRecords` resolver has settled. + */ + isResolving: boolean; + + /** + * Whether the user has at least one published post overall. Used by + * the surface to bifurcate the empty state between "write your first + * post" (no history) and "broaden the search" (history exists but + * nothing matches the current filter). + */ + hasAnyPosts: boolean; +} + +/** + * Hook options. All optional; defaults to the `one-year-ago` preset. + */ +export interface UseOnThisDayPostOptions { + timeRange?: TimeRange; + customDate?: string; +} + +/** + * Formats a `Date` as the `MM-DD` string expected by the + * `on_this_day` REST parameter. + * + * @param date Date to format. + * @return Zero-padded month and day separated by a dash. + */ +export function toMonthDay( date: Date ): string { + const month = String( date.getMonth() + 1 ).padStart( 2, '0' ); + const day = String( date.getDate() ).padStart( 2, '0' ); + return `${ month }-${ day }`; +} + +/** + * Formats a `Date` as `YYYY-MM-DD` in the local calendar, suitable for + * building `after`/`before` REST parameters against a specific day. + * + * @param date Date to format. + * @return Year, month, and day joined with dashes. + */ +function toYearMonthDay( date: Date ): string { + const year = String( date.getFullYear() ); + const month = String( date.getMonth() + 1 ).padStart( 2, '0' ); + const day = String( date.getDate() ).padStart( 2, '0' ); + return `${ year }-${ month }-${ day }`; +} + +/** + * Returns `now` shifted back by `years`. Calendar-safe via `setFullYear` + * (leap-day shifts to Feb 28 of the target year by JavaScript spec). + * + * @param now Reference date to shift back from. + * @param years Number of years to subtract. + * @return New `Date` shifted into the past. + */ +function shiftYears( now: Date, years: number ): Date { + const next = new Date( now ); + next.setFullYear( next.getFullYear() - years ); + return next; +} + +/** + * Translates the selected time range into the REST query fragment that + * narrows the posts collection to the matching day(s). + * + * - `one-year-ago` / `five-years-ago` / `ten-years-ago`: a single + * calendar day in the past, queried with native `after` / `before`. + * - `oldest-on-this-day`: matches today's month and day across all + * years, using the `on_this_day` filter registered by the plugin. + * - `custom`: a single calendar day specified by `customDate` (`YYYY-MM-DD`). + * + * @param timeRange Selected preset. + * @param customDate Calendar day for the `custom` preset; ignored otherwise. + * @param now Reference date used to anchor the relative presets. + * @return REST query fragment, or `null` when the inputs do not resolve + * to a valid query (e.g. `custom` without a parseable date). + */ +export function buildTimeRangeQuery( + timeRange: TimeRange, + customDate: string | undefined, + now: Date +): Record< string, string > | null { + if ( timeRange === 'oldest-on-this-day' ) { + return { on_this_day: toMonthDay( now ) }; + } + + let target: Date | null = null; + + if ( timeRange === 'one-year-ago' ) { + target = shiftYears( now, 1 ); + } else if ( timeRange === 'five-years-ago' ) { + target = shiftYears( now, 5 ); + } else if ( timeRange === 'ten-years-ago' ) { + target = shiftYears( now, 10 ); + } else if ( timeRange === 'custom' && customDate ) { + const parsed = new Date( `${ customDate }T00:00:00` ); + if ( ! Number.isNaN( parsed.getTime() ) ) { + target = parsed; + } + } + + if ( ! target ) { + return null; + } + + const ymd = toYearMonthDay( target ); + return { + after: `${ ymd }T00:00:00`, + before: `${ ymd }T23:59:59`, + }; +} + +/** + * Reads the post that matches the selected time range, then resolves + * its featured image via the `attachment` entity. + * + * Calls the native posts collection with either the `on_this_day=MM-DD` + * parameter (for the cross-year preset) or `after`/`before` (for a + * specific day). When the post has a non-zero `featured_media`, a + * second core-data selector fetches the attachment record; the URL is + * flattened into `featuredImageUrl` for the view. Both requests are + * cached and deduplicated by core-data. + * + * @param options Hook options. + * @param options.timeRange Preset that drives which post the hook surfaces. + * Defaults to `oldest-on-this-day`. + * @param options.customDate Calendar day (`YYYY-MM-DD`) used when + * `timeRange` is `custom`. + * @return The matching post and a resolution flag. + */ +export default function useOnThisDayPost( { + timeRange = 'oldest-on-this-day', + customDate, +}: UseOnThisDayPostOptions = {} ): UseOnThisDayPostResult { + return useSelect( + ( select ) => { + const { getEntityRecords, getEntityRecord } = select( coreStore ); + + const anyPostsProbe = getEntityRecords( 'postType', 'post', { + per_page: 1, + status: 'publish', + _fields: 'id', + } ) as Array< { id: number } > | null; + const probeIsResolving = anyPostsProbe === null; + const hasAnyPosts = + anyPostsProbe !== null && anyPostsProbe.length > 0; + + const dateQuery = buildTimeRangeQuery( + timeRange, + customDate, + new Date() + ); + + if ( ! dateQuery ) { + return { + post: null, + isResolving: probeIsResolving, + hasAnyPosts, + }; + } + + const records = getEntityRecords( 'postType', 'post', { + ...dateQuery, + per_page: 1, + orderby: 'date', + order: 'asc', + _fields: + 'id,date,title,excerpt,link,featured_media,comment_count', + } ) as RawPost[] | null; + + if ( records === null ) { + return { post: null, isResolving: true, hasAnyPosts }; + } + + const raw = records[ 0 ]; + + if ( ! raw ) { + return { + post: null, + isResolving: probeIsResolving, + hasAnyPosts, + }; + } + + const mediaId = raw.featured_media ?? 0; + const media = mediaId + ? ( getEntityRecord( 'postType', 'attachment', mediaId, { + context: 'view', + } ) as AttachmentRecord | undefined | null ) + : null; + + return { + post: { + id: raw.id, + date: raw.date, + title: raw.title, + excerpt: raw.excerpt, + link: raw.link, + commentCount: Number( raw.comment_count ?? 0 ), + featuredImageUrl: media?.source_url ?? null, + }, + isResolving: false, + hasAnyPosts, + }; + }, + [ timeRange, customDate ] + ); +} diff --git a/widgets/on-this-day/render.tsx b/widgets/on-this-day/render.tsx new file mode 100644 index 00000000000000..4c598d0eded213 --- /dev/null +++ b/widgets/on-this-day/render.tsx @@ -0,0 +1,36 @@ +/** + * Internal dependencies + */ +import type { WidgetRenderProps } from '../../routes/dashboard/widget-types'; +import { useOnThisDayPost } from './hooks/use-on-this-day-post'; +import type { TimeRange } from './hooks/use-on-this-day-post'; +import { + OnThisDayView, + type BackgroundEffect, +} from './components/on-this-day-view'; + +interface OnThisDayAttributes { + timeRange?: TimeRange; + customDate?: string; + effect?: BackgroundEffect; +} + +/** + * Renders a published post resolved by the selected time range. The + * surrounding surface owns the chrome (header, footer, error boundary); + * this component emits only the widget body, delegating layout to + * {@link OnThisDayView}. + * + * @param props Component props. + * @param props.attributes Widget attributes set on this instance. + */ +export default function OnThisDay( { + attributes, +}: WidgetRenderProps< OnThisDayAttributes > ) { + const data = useOnThisDayPost( { + timeRange: attributes.timeRange, + customDate: attributes.customDate, + } ); + + return ; +} diff --git a/widgets/on-this-day/widget.json b/widgets/on-this-day/widget.json new file mode 100644 index 00000000000000..17b27f039b29d1 --- /dev/null +++ b/widgets/on-this-day/widget.json @@ -0,0 +1,7 @@ +{ + "name": "core/on-this-day", + "title": "On This Day", + "description": "The oldest post published on this day in history.", + "category": "dashboard", + "presentation": "full-bleed" +} diff --git a/widgets/on-this-day/widget.ts b/widgets/on-this-day/widget.ts new file mode 100644 index 00000000000000..02ba8805c76c15 --- /dev/null +++ b/widgets/on-this-day/widget.ts @@ -0,0 +1,79 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { calendar } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import type { WidgetTypeMetadata } from '../../routes/dashboard/widget-types'; +import type { BackgroundEffect } from './components/on-this-day-view'; +import type { TimeRange } from './hooks/use-on-this-day-post'; + +/** + * Shape of the widget's persisted attributes. Bound to + * `WidgetTypeMetadata` below so the attribute schema and the example + * defaults are validated against this type. + */ +type OnThisDayWidgetType = { + timeRange?: TimeRange; + customDate?: string; + effect?: BackgroundEffect; +}; + +const widget: WidgetTypeMetadata< OnThisDayWidgetType > = { + apiVersion: 1, + name: 'core/on-this-day', + title: __( 'On This Day' ), + description: __( 'A post published on this day in history.' ), + icon: calendar, + keywords: [ + __( 'memory' ), + __( 'archive' ), + __( 'anniversary' ), + __( 'history' ), + ], + attributes: [ + { + id: 'timeRange', + label: __( 'Time range' ), + type: 'text', + elements: [ + { value: 'one-year-ago', label: __( 'One year ago' ) }, + { value: 'five-years-ago', label: __( 'Five years ago' ) }, + { value: 'ten-years-ago', label: __( 'Ten years ago' ) }, + { + value: 'oldest-on-this-day', + label: __( 'Oldest on this day (any year)' ), + }, + { value: 'custom', label: __( 'Custom date' ) }, + ], + }, + { + id: 'customDate', + label: __( 'Custom date (YYYY-MM-DD)' ), + type: 'text', + }, + { + id: 'effect', + label: __( 'Background effect' ), + type: 'text', + elements: [ + { value: 'vintage', label: __( 'Vintage memory' ) }, + { value: 'dim-saturate', label: __( 'Dim + saturate' ) }, + { value: 'dim-blur', label: __( 'Dim + blur' ) }, + { value: 'grayscale', label: __( 'Grayscale' ) }, + { value: 'none', label: __( 'No filter' ) }, + ], + }, + ], + example: { + attributes: { + timeRange: 'oldest-on-this-day', + effect: 'vintage', + }, + }, +}; + +export default widget; diff --git a/widgets/package.json b/widgets/package.json new file mode 100644 index 00000000000000..7e81f1b8ddbdf4 --- /dev/null +++ b/widgets/package.json @@ -0,0 +1,38 @@ +{ + "name": "@wordpress/widgets-app", + "version": "0.0.0", + "description": "Application widgets surfaced by the Gutenberg editor.", + "private": true, + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "widgets" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/widgets", + "repository": { + "type": "git", + "url": "git+https://github.com/WordPress/gutenberg.git", + "directory": "widgets" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "dependencies": { + "@wordpress/core-data": "file:../packages/core-data", + "@wordpress/data": "file:../packages/data", + "@wordpress/dataviews": "file:../packages/dataviews", + "@wordpress/date": "file:../packages/date", + "@wordpress/element": "file:../packages/element", + "@wordpress/html-entities": "file:../packages/html-entities", + "@wordpress/i18n": "file:../packages/i18n", + "@wordpress/icons": "file:../packages/icons", + "@wordpress/ui": "file:../packages/ui", + "@wordpress/url": "file:../packages/url", + "clsx": "^2.1.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/widgets/tsconfig.json b/widgets/tsconfig.json new file mode 100644 index 00000000000000..7882b230a3ba63 --- /dev/null +++ b/widgets/tsconfig.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "noEmit": true, + "emitDeclarationOnly": false, + "rootDir": ".", + "types": [ "style-imports", "react-css-custom-properties" ] + }, + "references": [ + { "path": "../packages/core-data" }, + { "path": "../packages/data" }, + { "path": "../packages/dataviews" }, + { "path": "../packages/date" }, + { "path": "../packages/element" }, + { "path": "../packages/html-entities" }, + { "path": "../packages/i18n" }, + { "path": "../packages/icons" }, + { "path": "../packages/ui" }, + { "path": "../packages/url" }, + { "path": "../routes/dashboard/widget-types" } + ], + "include": [ "**/*.ts", "**/*.tsx" ], + "exclude": [ "**/test/**", "**/build/**", "**/build-types/**" ] +}