Add initial /daily page content

This commit is contained in:
Oliver Davies 2025-07-04 08:27:59 +01:00
parent 34c173b1c9
commit fb3b58cf97
15 changed files with 885 additions and 1 deletions

View file

@ -0,0 +1,286 @@
uuid: 144c6b85-87cc-4c99-8f46-8a43fff416bf
langcode: en
status: true
dependencies:
config:
- node.type.daily_email
module:
- node
- user
id: recent_daily_emails
label: 'Recent daily emails'
module: views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
display:
default:
id: default
display_title: Default
display_plugin: default
position: 0
display_options:
title: 'Recent daily emails'
fields:
title:
id: title
table: node_field_data
field: title
relationship: none
group_type: group
admin_label: ''
plugin_id: field
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: string
settings:
link_to_entity: true
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
created:
id: created
table: node_field_data
field: created
relationship: none
group_type: group
admin_label: ''
entity_type: node
entity_field: created
plugin_id: field
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: timestamp
settings:
date_format: short
custom_date_format: ''
timezone: ''
tooltip:
date_format: long
custom_date_format: ''
time_diff:
enabled: false
future_format: '@interval hence'
past_format: '@interval ago'
granularity: 2
refresh: 60
description: ''
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
pager:
type: some
options:
offset: 0
items_per_page: 7
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: { }
empty: { }
sorts:
created:
id: created
table: node_field_data
field: created
relationship: none
group_type: group
admin_label: ''
entity_type: node
entity_field: created
plugin_id: date
order: DESC
expose:
label: ''
field_identifier: ''
exposed: false
granularity: second
arguments: { }
filters:
status:
id: status
table: node_field_data
field: status
entity_type: node
entity_field: status
plugin_id: boolean
value: '1'
group: 1
expose:
operator: ''
type:
id: type
table: node_field_data
field: type
entity_type: node
entity_field: type
plugin_id: bundle
value:
daily_email: daily_email
style:
type: html_list
row:
type: fields
options:
default_field_elements: true
inline:
title: title
created: created
separator: ' - '
hide_empty: false
query:
type: views_query
options:
query_comment: ''
disable_sql_rewrite: false
distinct: false
replica: false
query_tags: { }
relationships: { }
header:
area:
id: area
table: views
field: area
relationship: none
group_type: group
admin_label: ''
plugin_id: text
empty: false
content:
value: 'These are the emails I sent this week:'
format: markdown
tokenize: false
footer: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- 'user.node_grants:view'
- user.permissions
tags: { }
block_1:
id: block_1
display_title: Block
display_plugin: block
position: 1
display_options:
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- 'user.node_grants:view'
- user.permissions
tags: { }

View file

@ -0,0 +1,63 @@
{
"uuid": [
{
"value": "262bcabc-61d8-4b67-b4ab-ba39d86db38d"
}
],
"langcode": [
{
"value": "en"
}
],
"type": [
{
"target_id": "basic",
"target_type": "block_content_type",
"target_uuid": "2646e684-c917-4dd0-b502-1a73b9c10131"
}
],
"revision_created": [
{
"value": "2025-07-03T15:10:16+00:00"
}
],
"revision_user": [],
"status": [
{
"value": true
}
],
"info": [
{
"value": "Introduction text"
}
],
"changed": [
{
"value": "2025-07-03T15:10:16+00:00"
}
],
"reusable": [
{
"value": false
}
],
"default_langcode": [
{
"value": true
}
],
"revision_translation_affected": [
{
"value": true
}
],
"body": [
{
"value": "Subscribe to my daily newsletter for software professionals on software development and delivery, Drupal, DevOps, community, and open-source.",
"format": "markdown",
"processed": "<p>Subscribe to my daily newsletter for software professionals on software development and delivery, Drupal, DevOps, community, and open-source.<\/p>\n",
"summary": ""
}
]
}

View file

@ -0,0 +1,63 @@
{
"uuid": [
{
"value": "f451cc6b-1555-4bb2-ad3c-6d1bb4ee6bf5"
}
],
"langcode": [
{
"value": "en"
}
],
"type": [
{
"target_id": "basic",
"target_type": "block_content_type",
"target_uuid": "2646e684-c917-4dd0-b502-1a73b9c10131"
}
],
"revision_created": [
{
"value": "2025-07-03T15:11:02+00:00"
}
],
"revision_user": [],
"status": [
{
"value": true
}
],
"info": [
{
"value": "Browse the archive"
}
],
"changed": [
{
"value": "2025-07-03T15:11:02+00:00"
}
],
"reusable": [
{
"value": false
}
],
"default_langcode": [
{
"value": true
}
],
"revision_translation_affected": [
{
"value": true
}
],
"body": [
{
"value": "Not sure? [Browse the archive \u2192](\/archive)",
"format": "markdown",
"processed": "<p>Not sure? <a href=\"http:\/\/localhost:8888\/archive\">Browse the archive \u2192<\/a><\/p>\n",
"summary": ""
}
]
}

View file

@ -6625,5 +6625,13 @@
],
"path_alias.dfdfc073-dc09-49ae-a325-aacebe1911b6": [
"node.465a0fa7-eb20-4d3e-b30d-9d03187b13ca"
]
],
"node.57c15821-f744-45ce-960e-5f77d41c3ad3": [
"user.b8966985-d4b2-42a7-a319-2e94ccfbb849"
],
"path_alias.0d01ff0e-6edd-4b9e-a93d-cf831bb6212f": [
"node.57c15821-f744-45ce-960e-5f77d41c3ad3"
],
"block_content.262bcabc-61d8-4b67-b4ab-ba39d86db38d": [],
"block_content.f451cc6b-1555-4bb2-ad3c-6d1bb4ee6bf5": []
}

View file

@ -0,0 +1,95 @@
{
"uuid": [
{
"value": "57c15821-f744-45ce-960e-5f77d41c3ad3"
}
],
"langcode": [
{
"value": "en"
}
],
"type": [
{
"target_id": "page",
"target_type": "node_type",
"target_uuid": "a6dfb70f-21fc-46e0-aa8c-45c5d0cef75f"
}
],
"revision_timestamp": [
{
"value": "2025-07-03T16:12:47+00:00"
}
],
"revision_uid": [
{
"target_type": "user",
"target_uuid": "b8966985-d4b2-42a7-a319-2e94ccfbb849"
}
],
"revision_log": [],
"status": [
{
"value": true
}
],
"uid": [
{
"target_type": "user",
"target_uuid": "b8966985-d4b2-42a7-a319-2e94ccfbb849"
}
],
"title": [
{
"value": "Sign up for the Daily Drupaler Email List"
}
],
"created": [
{
"value": "2025-07-03T15:08:56+00:00"
}
],
"changed": [
{
"value": "2025-07-03T16:12:47+00:00"
}
],
"promote": [
{
"value": false
}
],
"sticky": [
{
"value": false
}
],
"default_langcode": [
{
"value": true
}
],
"revision_translation_affected": [
{
"value": true
}
],
"path": [
{
"alias": "\/daily",
"langcode": "en"
}
],
"body": [],
"field_seo_analysis": [
{
"status": "0",
"focus_keyword": "",
"title": null,
"description": null
}
],
"field_seo_description": [],
"field_seo_image": [],
"field_seo_title": []
}

View file

@ -0,0 +1,27 @@
{
"uuid": [
{
"value": "0d01ff0e-6edd-4b9e-a93d-cf831bb6212f"
}
],
"langcode": [
{
"value": "en"
}
],
"path": [
{
"value": "\/node\/57c15821-f744-45ce-960e-5f77d41c3ad3"
}
],
"alias": [
{
"value": "\/daily"
}
],
"status": [
{
"value": true
}
]
}

View file

@ -30,6 +30,20 @@ function opd_daily_emails_entity_presave(Drupal\Core\Entity\EntityInterface $ent
\Drupal::service(AddRandomCtaToDailyEmail::class)($entity);
}
/**
* Implements hook_form_FORM_ID_alter().
*
* @param array{'#action': string, '#attributes': array<non-empty-string, mixed>} $form
*/
function opd_daily_emails_form_opd_daily_emails_kit_subscription_form_alter(array &$form): void {
$form['#action'] = 'https://app.convertkit.com/forms/3546728/subscriptions';
$form['#attributes']['data-format'] = 'inline';
$form['#attributes']['data-sv-form'] = '3546728';
$form['#attributes']['data-uid'] = 'f0c1d2b57f';
$form['#attributes']['data-version'] = 5;
}
/**
* Implements hook_token_info().
*

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Drupal\opd_daily_emails\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
final class KitSubscriptionForm extends FormBase {
public function getFormId(): string {
return 'opd_daily_emails_kit_subscription_form';
}
/**
* @param array<non-empty-string, array{}> $form
* @param FormStateInterface $formState
*
* @return array<non-empty-string, mixed>
*/
public function buildForm(array $form, FormStateInterface $formState): array {
$form['email_address'] = [
'#placeholder' => 'me@example.com',
'#title' => $this->t('What is your best email address?'),
'#type' => 'email',
];
$form['actions']['submit'] = [
'#attributes' => [
'class' => ['inline-flex justify-center items-center py-3 px-6 w-full font-medium text-white no-underline rounded-md border duration-200 ease-in-out hover:bg-white focus:bg-white border-blue-primary bg-blue-primary transition-color hover:text-blue-primary focus:text-blue-primary'],
'data-element' => 'submit',
],
'#type' => 'button',
'#value' => $this->t('Get daily emails') . ' →',
];
return $form;
}
/**
* @param array<non-empty-string, mixed> $form
* @param FormStateInterface $formState
*/
public function submitForm(array &$form, FormStateInterface $formState): void {
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Drupal\opd_daily_emails\Plugin\Block;
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\opd_daily_emails\Form\KitSubscriptionForm;
use Symfony\Component\DependencyInjection\ContainerInterface;
#[Block(
admin_label: new TranslatableMarkup("Kit Subscription Block"),
category: new TranslatableMarkup("Daily emails"),
id: "opd_daily_emails_kit_subscription_block",
)]
final class KitSubscriptionFormBlock extends BlockBase implements ContainerFactoryPluginInterface {
/**
* @param array<string, mixed> $configuration
* @param string $pluginId
* @param array<string, mixed> $pluginDefinition
* @param FormBuilderInterface $formBuilder
*/
public function __construct(
array $configuration,
string $pluginId,
array $pluginDefinition,
private FormBuilderInterface $formBuilder,
) {
parent::__construct($configuration, $pluginId, $pluginDefinition);
}
/**
* @return array<non-empty-string, mixed>
*/
public function build(): array {
return $this->formBuilder->getForm(KitSubscriptionForm::class);
}
/**
* @param ContainerInterface $container
* @param array<string, mixed> $configuration
* @param string $pluginId
* @param array<string, mixed> $pluginDefinition
*/
public static function create(ContainerInterface $container, array $configuration, $pluginId, $pluginDefinition): self {
return new static(
$configuration,
$pluginId,
$pluginDefinition,
$container->get(FormBuilderInterface::class),
);
}
}

View file

@ -1,5 +1,6 @@
@import "tailwindcss";
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@theme {

View file

@ -0,0 +1,28 @@
{#
/**
* @file
* Default theme implementation for a form element label.
*
* Available variables:
* - title: The label's text.
* - title_display: Elements title_display setting.
* - required: An indicator for whether the associated form element is required.
* - attributes: A list of HTML attributes for the label.
*
* @see template_preprocess_form_element_label()
*
* @ingroup themeable
*/
#}
{%
set classes = [
'text-lg',
title_display == 'after' ? 'option',
title_display == 'invisible' ? 'visually-hidden',
required ? 'js-form-required',
required ? 'form-required',
]
%}
{% if title is not empty or required -%}
<label{{ attributes.addClass(classes) }}>{{ title }}</label>
{%- endif %}

View file

@ -0,0 +1,15 @@
{#
/**
* @file
* Default theme implementation for an 'input' #type form element.
*
* Available variables:
* - attributes: A list of HTML attributes for the input element.
* - children: Optional additional rendered elements.
*
* @see template_preprocess_input()
*
* @ingroup themeable
*/
#}
<input{{ attributes.addClass('form-input mt-1 rounded block w-full') }} />{{ children }}

View file

@ -0,0 +1,17 @@
{#
/**
* @file
* Default theme implementation for an 'input' #type form element.
*
* Available variables:
* - attributes: A list of HTML attributes for the input element.
* - children: Optional additional rendered elements.
*
* @see template_preprocess_input()
*
* @ingroup themeable
*/
#}
<div class="mt-4">
<input{{ attributes.addClass('text-lg') }} />{{ children }}
</div>

View file

@ -0,0 +1,87 @@
{#
/**
* @file
* Default theme implementation to display a node.
*
* Available variables:
* - node: The node entity with limited access to object properties and methods.
* Only method names starting with "get", "has", or "is" and a few common
* methods such as "id", "label", and "bundle" are available. For example:
* - node.getCreatedTime() will return the node creation timestamp.
* - node.hasField('field_example') returns TRUE if the node bundle includes
* field_example. (This does not indicate the presence of a value in this
* field.)
* - node.isPublished() will return whether the node is published or not.
* Calling other methods, such as node.delete(), will result in an exception.
* See \Drupal\node\Entity\Node for a full list of public properties and
* methods for the node object.
* - label: (optional) The title of the node.
* - content: All node items. Use {{ content }} to print them all,
* or print a subset such as {{ content.field_example }}. Use
* {{ content|without('field_example') }} to temporarily suppress the printing
* of a given child element.
* - author_picture: The node author user entity, rendered using the "compact"
* view mode.
* - date: (optional) Themed creation date field.
* - author_name: (optional) Themed author name field.
* - url: Direct URL of the current node.
* - display_submitted: Whether submission information should be displayed.
* - attributes: HTML attributes for the containing element.
* The attributes.class element may contain one or more of the following
* classes:
* - node: The current template type (also known as a "theming hook").
* - node--type-[type]: The current node type. For example, if the node is an
* "Article" it would result in "node--type-article". Note that the machine
* name will often be in a short form of the human readable label.
* - node--view-mode-[view_mode]: The View Mode of the node; for example, a
* teaser would result in: "node--view-mode-teaser", and
* full: "node--view-mode-full".
* The following are controlled through the node publishing options.
* - node--promoted: Appears on nodes promoted to the front page.
* - node--sticky: Appears on nodes ordered above other non-sticky nodes in
* teaser listings.
* - node--unpublished: Appears on unpublished nodes visible only to site
* admins.
* - title_attributes: Same as attributes, except applied to the main title
* tag that appears in the template.
* - content_attributes: Same as attributes, except applied to the main
* content tag that appears in the template.
* - author_attributes: Same as attributes, except applied to the author of
* the node tag that appears in the template.
* - title_prefix: Additional output populated by modules, intended to be
* displayed in front of the main title tag that appears in the template.
* - title_suffix: Additional output populated by modules, intended to be
* displayed after the main title tag that appears in the template.
* - view_mode: View mode; for example, "teaser" or "full".
* - page: Flag for the full page state. Will be true if view_mode is 'full'.
*
* @see template_preprocess_node()
*
* @ingroup themeable
*/
#}
<article{{ attributes }}>
{{ title_prefix }}
{% if label and not page %}
<h2{{ title_attributes }}>
<a href="{{ url }}" rel="bookmark">{{ label }}</a>
</h2>
{% endif %}
{{ title_suffix }}
{% if display_submitted %}
<footer>
{{ author_picture }}
<div{{ author_attributes }}>
{% trans %}Submitted by {{ author_name }} on {{ date }}{% endtrans %}
</div>
</footer>
{% endif %}
<div{{ content_attributes.addClass('space-y-6') }}>
{{ content }}
</div>
</article>

View file

@ -0,0 +1,73 @@
{#
/**
* @file
* Default theme implementation for main view template.
*
* Available variables:
* - attributes: Remaining HTML attributes for the element.
* - css_name: A CSS-safe version of the view name.
* - css_class: The user-specified classes names, if any.
* - header: The optional header.
* - footer: The optional footer.
* - rows: The results of the view query, if any.
* - empty: The content to display if there are no rows.
* - pager: The optional pager next/prev links to display.
* - exposed: Exposed widget form/info to display.
* - feed_icons: Optional feed icons to display.
* - more: An optional link to the next page of results.
* - title: Title of the view, only used when displaying in the admin preview.
* - title_prefix: Additional output populated by modules, intended to be
* displayed in front of the view title.
* - title_suffix: Additional output populated by modules, intended to be
* displayed after the view title.
* - attachment_before: An optional attachment view to be displayed before the
* view content.
* - attachment_after: An optional attachment view to be displayed after the
* view content.
* - dom_id: Unique id for every view being printed to give unique class for
* JavaScript.
*
* @see template_preprocess_views_view()
*
* @ingroup themeable
*/
#}
{%
set classes = [
dom_id ? 'js-view-dom-id-' ~ dom_id,
]
%}
<div{{ attributes.addClass(classes) }}>
{{ title_prefix }}
{{ title }}
{{ title_suffix }}
<div class="prose prose-p:text-black prose-a:font-light prose-a:text-blue-primary prose-p:text-lg prose-blockquote:border-blue-primary dark:marker:text-white prose-li:my-1 prose-li:text-lg prose-figcaption:text-white prose-li:text-black marker:text-black dark:prose-p:text-white dark:prose-invert dark:prose-a:text-blue-400 dark:prose-blockquote:border-blue-400 dark:prose-li:text-white prose-a:hover:no-underline prose-h2:text-xl prose-code:font-normal prose-h2:mb-4 prose-ul:my-3 dark:prose-hr:border-neutral-400 prose-code:before:content-[''] prose-code:after:content-[''] prose-pre:bg-neutral-200 prose-pre:text-black prose-pre:rounded-none prose-code:bg-neutral-200">
{% if header %}
<header>
{{ header }}
</header>
{% endif %}
{{ exposed }}
{{ attachment_before }}
{% if rows -%}
{{ rows }}
{% elseif empty -%}
{{ empty }}
{% endif %}
{{ pager }}
{{ attachment_after }}
{{ more }}
{% if footer %}
<footer>
{{ footer }}
</footer>
{% endif %}
{{ feed_icons }}
</div>
</div>