From 746b5187898c7f61900ef4b8f49029f0989fd1fa Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Mon, 20 Sep 2021 15:45:38 +0000
Subject: [PATCH] refactor: replace ui function components with class
 components + fix soundbites js

---
 DEPENDENCIES.md                               |   2 +-
 app/Helpers/components_helper.php             | 154 ++--------
 app/Helpers/form_helper.php                   | 131 ---------
 app/Resources/js/modules/Soundbites.ts        |  46 ++-
 app/Resources/styles/layout.css               |   2 +-
 app/Views/Components/Alert.php                |   2 +-
 app/Views/Components/Forms/Field.php          |  15 +-
 app/Views/Components/Forms/Helper.php         |   2 +-
 app/Views/Components/Forms/Select.php         |   8 +-
 app/Views/Components/IconButton.php           |  18 +-
 .../Admin/Controllers/EpisodeController.php   |  50 ++--
 .../Install/Controllers/InstallController.php |   2 +-
 modules/Install/Language/en/Install.php       |   1 +
 modules/Install/Language/fr/Install.php       |   1 +
 themes/cp_admin/contributor/list.php          |  37 +--
 themes/cp_admin/episode/create.php            |  10 +-
 themes/cp_admin/episode/edit.php              |  10 +-
 themes/cp_admin/episode/list.php              |   5 +-
 themes/cp_admin/episode/persons.php           |  18 +-
 themes/cp_admin/episode/publish.php           |   2 +-
 themes/cp_admin/episode/publish_edit.php      |   2 +-
 themes/cp_admin/episode/soundbites.php        | 262 ++++--------------
 themes/cp_admin/fediverse/blocked_actors.php  |  18 +-
 themes/cp_admin/fediverse/blocked_domains.php |  16 +-
 themes/cp_admin/page/create.php               |   3 -
 themes/cp_admin/page/list.php                 |  38 +--
 themes/cp_admin/page/view.php                 |   5 +-
 themes/cp_admin/person/create.php             |   8 +-
 themes/cp_admin/person/edit.php               |   8 +-
 themes/cp_admin/person/list.php               |  12 +-
 themes/cp_admin/person/view.php               |  12 +-
 themes/cp_admin/podcast/create.php            |  14 +-
 themes/cp_admin/podcast/edit.php              |  26 +-
 themes/cp_admin/podcast/import.php            |   6 +-
 themes/cp_admin/podcast/list.php              |  17 +-
 themes/cp_admin/podcast/persons.php           |  25 +-
 themes/cp_admin/podcast/platforms.php         |   6 +-
 themes/cp_admin/podcast/view.php              |  17 +-
 themes/cp_admin/user/create.php               |  64 ++---
 themes/cp_admin/user/edit.php                 |  31 +--
 themes/cp_admin/user/list.php                 |  57 +---
 themes/cp_app/embeddable_player.php           |   6 +-
 .../comment_actions_authenticated.php         |   8 +-
 ...omment_actions_from_post_authenticated.php |   8 +-
 .../comment_reply_actions_authenticated.php   |   8 +-
 .../comment_with_replies_authenticated.php    |  47 +---
 .../post_with_replies_authenticated.php       |  47 +---
 .../cp_app/podcast/activity_authenticated.php |  61 ++--
 .../cp_app/podcast/episode_authenticated.php  |  98 ++-----
 themes/cp_app/podcast/follow.php              |  39 +--
 themes/cp_app/podcast/post_remote_action.php  |  44 +--
 themes/cp_auth/forgot.php                     |  37 +--
 themes/cp_auth/login.php                      |  52 ++--
 themes/cp_auth/register.php                   |  63 ++---
 themes/cp_auth/reset.php                      |  68 ++---
 themes/cp_install/_layout.php                 |   2 +-
 themes/cp_install/cache_config.php            |  51 ++--
 themes/cp_install/create_superadmin.php       |  76 ++---
 themes/cp_install/database_config.php         | 120 ++++----
 themes/cp_install/instance_config.php         | 114 +++-----
 60 files changed, 542 insertions(+), 1570 deletions(-)

diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md
index 7819536eaf..f8bc677149 100644
--- a/DEPENDENCIES.md
+++ b/DEPENDENCIES.md
@@ -52,7 +52,7 @@ Other:
 
 - [Kumbh Sans](https://fonts.google.com/specimen/Kumbh+Sans)
   ([Open Font License](https://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL))
-- [Montserrat](https://fonts.google.com/specimen/Montserrat)
+- [Inter](https://fonts.google.com/specimen/Inter)
   ([Open Font License](https://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL))
 - [RemixIcon](https://remixicon.com/)
   ([Apache License 2.0](https://github.com/Remix-Design/RemixIcon/blob/master/License))
diff --git a/app/Helpers/components_helper.php b/app/Helpers/components_helper.php
index 45d12a1577..a858da80fc 100644
--- a/app/Helpers/components_helper.php
+++ b/app/Helpers/components_helper.php
@@ -12,140 +12,6 @@ use App\Entities\Person;
 use CodeIgniter\I18n\Time;
 use CodeIgniter\View\Table;
 
-if (! function_exists('button')) {
-    /**
-     * Button component
-     *
-     * Creates a stylized button or button like anchor tag if the URL is defined.
-     *
-     * @param array<string, string|null|bool> $customOptions button options: variant, size, iconLeft, iconRight
-     * @param array<string, string> $customAttributes Additional attributes
-     */
-    function button(
-        string $label = '',
-        string $uri = '',
-        array $customOptions = [],
-        array $customAttributes = []
-    ): string {
-        $defaultOptions = [
-            'variant' => 'default',
-            'size' => 'base',
-            'iconLeft' => null,
-            'iconRight' => null,
-            'isSquared' => false,
-        ];
-        $options = array_merge($defaultOptions, $customOptions);
-
-        $baseClass =
-            'inline-flex items-center font-semibold shadow-xs rounded-full focus:outline-none focus:ring';
-
-        $variantClass = [
-            'default' => 'text-black bg-gray-300 hover:bg-gray-400',
-            'primary' => 'text-white bg-pine-500 hover:bg-pine-800',
-            'secondary' => 'text-white bg-gray-700 hover:bg-gray-800',
-            'accent' => 'text-white bg-rose-600 hover:bg-rose-800',
-            'success' => 'text-white bg-green-600 hover:bg-green-700',
-            'danger' => 'text-white bg-red-600 hover:bg-red-700',
-            'warning' => 'text-black bg-yellow-500 hover:bg-yellow-600',
-            'info' => 'text-white bg-blue-500 hover:bg-blue-600',
-        ];
-
-        $sizeClass = [
-            'small' => 'text-xs md:text-sm',
-            'base' => 'text-sm md:text-base',
-            'large' => 'text-lg md:text-xl',
-        ];
-
-        $basePaddings = [
-            'small' => 'px-2 md:px-3 md:py-1',
-            'base' => 'px-3 py-1 md:px-4 md:py-2',
-            'large' => 'px-3 py-2 md:px-5',
-        ];
-
-        $squaredPaddings = [
-            'small' => 'p-1',
-            'base' => 'p-2',
-            'large' => 'p-3',
-        ];
-
-        $buttonClass =
-            $baseClass .
-            ' ' .
-            ($options['isSquared']
-                ? $squaredPaddings[$options['size']]
-                : $basePaddings[$options['size']]) .
-            ' ' .
-            $sizeClass[$options['size']] .
-            ' ' .
-            $variantClass[$options['variant']];
-
-        if (array_key_exists('class', $customAttributes)) {
-            $buttonClass .= ' ' . $customAttributes['class'];
-            unset($customAttributes['class']);
-        }
-
-        if ($options['iconLeft']) {
-            $label = icon((string) $options['iconLeft'], 'mr-2') . $label;
-        }
-
-        if ($options['iconRight']) {
-            $label .= icon((string) $options['iconRight'], 'ml-2');
-        }
-
-        if ($uri !== '') {
-            return anchor($uri, $label, array_merge([
-                'class' => $buttonClass,
-            ], $customAttributes));
-        }
-
-        $defaultButtonAttributes = [
-            'type' => 'button',
-        ];
-        $attributes = stringify_attributes(array_merge($defaultButtonAttributes, $customAttributes));
-
-        return <<<CODE_SAMPLE
-            <button class="{$buttonClass}" {$attributes}>
-            {$label}
-            </button>
-        CODE_SAMPLE;
-    }
-}
-
-// ------------------------------------------------------------------------
-
-if (! function_exists('icon_button')) {
-    /**
-     * Icon Button component
-     *
-     * Abstracts the `button()` helper to create a stylized icon button
-     *
-     * @param string $icon The button icon
-     * @param string $title The button label
-     * @param array<string, string|null|bool>  $customOptions button options: variant, size, iconLeft, iconRight
-     * @param array<string, string>  $customAttributes Additional attributes
-     */
-    function icon_button(
-        string $icon,
-        string $title,
-        string $uri = '',
-        array $customOptions = [],
-        array $customAttributes = []
-    ): string {
-        $defaultOptions = [
-            'isSquared' => true,
-        ];
-        $options = array_merge($defaultOptions, $customOptions);
-
-        $defaultAttributes = [
-            'title' => $title,
-            'data-toggle' => 'tooltip',
-            'data-placement' => 'bottom',
-        ];
-        $attributes = array_merge($defaultAttributes, $customAttributes);
-
-        return button(icon($icon), $uri, $options, $attributes);
-    }
-}
 // ------------------------------------------------------------------------
 
 if (! function_exists('hint_tooltip')) {
@@ -296,13 +162,14 @@ if (! function_exists('publication_button')) {
                 break;
         }
 
-        return button($label, $route, [
-            'variant' => $variant,
-            'iconLeft' => $iconLeft,
-        ]);
+        return <<<CODE_SAMPLE
+            <Button variant="{$variant}" uri="{$route}" iconLeft="{$iconLeft}" >{$label}</Button>
+        CODE_SAMPLE;
     }
 }
+
 // ------------------------------------------------------------------------
+
 if (! function_exists('episode_numbering')) {
     /**
      * Returns relevant translated episode numbering.
@@ -354,6 +221,9 @@ if (! function_exists('episode_numbering')) {
             '</span>';
     }
 }
+
+// ------------------------------------------------------------------------
+
 if (! function_exists('location_link')) {
     /**
      * Returns link to display from location info
@@ -377,7 +247,9 @@ if (! function_exists('location_link')) {
         );
     }
 }
+
 // ------------------------------------------------------------------------
+
 if (! function_exists('person_list')) {
     /**
      * Returns list of persons images
@@ -430,7 +302,9 @@ if (! function_exists('person_list')) {
         return $personList . '</div>';
     }
 }
+
 // ------------------------------------------------------------------------
+
 if (! function_exists('play_episode_button')) {
     /**
      * Returns play episode button
@@ -462,7 +336,9 @@ if (! function_exists('play_episode_button')) {
         CODE_SAMPLE;
     }
 }
+
 // ------------------------------------------------------------------------
+
 if (! function_exists('audio_player')) {
     /**
      * Returns audio player
@@ -500,7 +376,9 @@ if (! function_exists('audio_player')) {
         CODE_SAMPLE;
     }
 }
+
 // ------------------------------------------------------------------------
+
 if (! function_exists('relative_time')) {
     function relative_time(Time $time, string $class = ''): string
     {
diff --git a/app/Helpers/form_helper.php b/app/Helpers/form_helper.php
index 3ff660bd2c..5b732a2e68 100644
--- a/app/Helpers/form_helper.php
+++ b/app/Helpers/form_helper.php
@@ -8,137 +8,6 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-if (! function_exists('form_section')) {
-    /**
-     * Form section
-     *
-     * Used to produce a responsive form section with a title and subtitle. To close section, use form_section_close()
-     *
-     * @param string $title The section title
-     * @param string $subtitle The section subtitle
-     * @param array<string, string>  $attributes  Additional attributes
-     */
-    function form_section(
-        string $title = '',
-        string $subtitle = '',
-        array $attributes = [],
-        string $customSubtitleClass = ''
-    ): string {
-        $subtitleClass = 'text-sm text-gray-600';
-        if ($customSubtitleClass !== '') {
-            $subtitleClass = $customSubtitleClass;
-        }
-
-        $section =
-            '<div class="flex flex-wrap w-full gap-6 mb-8"' .
-            stringify_attributes($attributes) .
-            ">\n";
-
-        $info =
-            '<div class="w-full max-w-xs"><h2 class="text-lg font-semibold">' .
-            $title .
-            '</h2><p class="' .
-            $subtitleClass .
-            '">' .
-            $subtitle .
-            '</p></div>';
-
-        return $section . $info . '<div class="flex flex-col w-full max-w-lg">';
-    }
-}
-
-//--------------------------------------------------------------------
-
-if (! function_exists('form_section_close')) {
-    /**
-     * Form Section close Tag
-     */
-    function form_section_close(string $extra = ''): string
-    {
-        return '</div></div>' . $extra;
-    }
-}
-
-//--------------------------------------------------------------------
-
-if (! function_exists('form_switch')) {
-    /**
-     * Form Checkbox Switch
-     *
-     * Abstracts form_label to stylize it as a switch toggle
-     *
-     * @param mixed[] $data
-     * @param mixed[] $extra
-     */
-    function form_switch(
-        string $label = '',
-        array $data = [],
-        string $value = '',
-        bool $checked = false,
-        string $class = '',
-        array $extra = []
-    ): string {
-        $data['class'] = 'form-switch';
-
-        return '<label class="relative inline-flex items-center' .
-            ' ' .
-            $class .
-            '">' .
-            form_checkbox($data, $value, $checked, $extra) .
-            '<span class="form-switch-slider"></span>' .
-            '<span class="ml-2">' .
-            $label .
-            '</span></label>';
-    }
-}
-
-//--------------------------------------------------------------------
-
-if (! function_exists('form_label')) {
-    /**
-     * Form Label Tag
-     *
-     * @param string $text The text to appear onscreen
-     * @param string $id         The id the label applies to
-     * @param array<string, string>  $attributes Additional attributes
-     * @param string  $hintText Hint text to add next to the label
-     * @param boolean  $isOptional adds an optional text if true
-     */
-    function form_label(
-        string $text = '',
-        string $id = '',
-        array $attributes = [],
-        string $hintText = '',
-        bool $isOptional = false
-    ): string {
-        $label = '<label';
-
-        if ($id !== '') {
-            $label .= ' for="' . $id . '"';
-        }
-
-        if (is_array($attributes) && $attributes) {
-            foreach ($attributes as $key => $val) {
-                $label .= ' ' . $key . '="' . $val . '"';
-            }
-        }
-
-        $labelContent = $text;
-        if ($isOptional) {
-            $labelContent .=
-                '<small class="ml-1 lowercase">(' .
-                lang('Common.optional') .
-                ')</small>';
-        }
-
-        if ($hintText !== '') {
-            $labelContent .= hint_tooltip($hintText, 'ml-1');
-        }
-
-        return $label . '>' . $labelContent . '</label>';
-    }
-}
-
 //--------------------------------------------------------------------
 
 if (! function_exists('form_dropdown')) {
diff --git a/app/Resources/js/modules/Soundbites.ts b/app/Resources/js/modules/Soundbites.ts
index 64833bc10e..ce17a716e9 100644
--- a/app/Resources/js/modules/Soundbites.ts
+++ b/app/Resources/js/modules/Soundbites.ts
@@ -59,36 +59,28 @@ const Soundbites = (): void => {
     if (soundbitePlayButtons) {
       for (let i = 0; i < soundbitePlayButtons.length; i++) {
         const soundbitePlayButton: HTMLButtonElement = soundbitePlayButtons[i];
+
         soundbitePlayButton.addEventListener("click", () => {
-          playSoundbite(
-            audioPlayer,
-            Number(soundbitePlayButton.dataset.soundbiteStartTime),
-            Number(soundbitePlayButton.dataset.soundbiteDuration)
-          );
-        });
-      }
-    }
+          // get values from inputs to play soundbite
+          const startTime: HTMLInputElement | null | undefined =
+            soundbitePlayButton.parentElement?.parentElement?.querySelector(
+              'input[data-field-type="start_time"]'
+            );
+          const duration: HTMLInputElement | null | undefined =
+            soundbitePlayButton.parentElement?.parentElement?.querySelector(
+              'input[data-field-type="duration"]'
+            );
+
+          console.log(soundbitePlayButton.parentElement);
 
-    const inputFields: NodeListOf<HTMLInputElement> | null =
-      document.querySelectorAll("input[data-type='soundbite-field']");
-    if (inputFields) {
-      for (let i = 0; i < inputFields.length; i++) {
-        const inputField: HTMLInputElement = inputFields[i];
-        const soundbitePlayButton: HTMLButtonElement | null =
-          document.querySelector(
-            `button[data-type="play-soundbite"][data-soundbite-id="${inputField.dataset.soundbiteId}"]`
-          );
-        if (soundbitePlayButton) {
-          if (inputField.dataset.fieldType == "start-time") {
-            inputField.addEventListener("input", () => {
-              soundbitePlayButton.dataset.soundbiteStartTime = inputField.value;
-            });
-          } else if (inputField.dataset.fieldType == "duration") {
-            inputField.addEventListener("input", () => {
-              soundbitePlayButton.dataset.soundbiteDuration = inputField.value;
-            });
+          if (startTime && duration) {
+            playSoundbite(
+              audioPlayer,
+              parseFloat(startTime.value),
+              parseFloat(duration.value)
+            );
           }
-        }
+        });
       }
     }
   }
diff --git a/app/Resources/styles/layout.css b/app/Resources/styles/layout.css
index 8faf449dc0..61b88e4c28 100644
--- a/app/Resources/styles/layout.css
+++ b/app/Resources/styles/layout.css
@@ -15,7 +15,7 @@
   }
 
   & .holy-grail__main {
-    @apply col-start-1 col-end-3 row-start-2 row-end-3;
+    @apply col-start-1 col-end-3 row-start-2 row-end-4;
   }
 
   & .holy-grail__footer {
diff --git a/app/Views/Components/Alert.php b/app/Views/Components/Alert.php
index 478c42c20f..41cf3bc51f 100644
--- a/app/Views/Components/Alert.php
+++ b/app/Views/Components/Alert.php
@@ -33,7 +33,7 @@ class Alert extends Component
         $attributes = stringify_attributes($this->attributes);
 
         return <<<HTML
-            <div class="{$class}" role="alert" {$attributes}>{$glyph}<div>{$title}{$this->slot}</div></div>
+            <div class="{$class}" role="alert" {$attributes}>{$glyph}<div>{$title}<p>{$this->slot}</p></div></div>
         HTML;
     }
 }
diff --git a/app/Views/Components/Forms/Field.php b/app/Views/Components/Forms/Field.php
index 10ec65da32..dd70c6390d 100644
--- a/app/Views/Components/Forms/Field.php
+++ b/app/Views/Components/Forms/Field.php
@@ -10,20 +10,25 @@ class Field extends FormComponent
 
     protected string $label = '';
 
-    protected ?string $helperText = null;
+    protected ?string $helper = null;
 
-    protected ?string $hintText = null;
+    protected ?string $hint = null;
 
     public function render(): string
     {
-        $helperText = $this->helperText === null ? '' : '<Forms.Helper>' . $this->helperText . '</Forms.Helper>';
+        $helperText = '';
+        if ($this->helper !== null) {
+            $helperId = $this->id . 'Help';
+            $helperText = '<Forms.Helper id="' . $helperId . '">' . $this->helper . '</Forms.Helper>';
+            $this->attributes['aria-describedby'] = $helperId;
+        }
 
         $labelAttributes = [
             'for' => $this->id,
             'isOptional' => $this->required ? 'false' : 'true',
         ];
-        if ($this->hintText) {
-            $labelAttributes['hint'] = $this->hintText;
+        if ($this->hint) {
+            $labelAttributes['hint'] = $this->hint;
         }
         $labelAttributes = stringify_attributes($labelAttributes);
 
diff --git a/app/Views/Components/Forms/Helper.php b/app/Views/Components/Forms/Helper.php
index 158fd3def0..866a3b0fc3 100644
--- a/app/Views/Components/Forms/Helper.php
+++ b/app/Views/Components/Forms/Helper.php
@@ -16,7 +16,7 @@ class Helper extends FormComponent
         $class = 'text-gray-600';
 
         return <<<HTML
-            <small class="{$class} {$this->class}">{$this->slot}</small>
+            <small id="{$this->id}" class="{$class} {$this->class}">{$this->slot}</small>
         HTML;
     }
 }
diff --git a/app/Views/Components/Forms/Select.php b/app/Views/Components/Forms/Select.php
index 2b8f4521ea..3cfd062e2f 100644
--- a/app/Views/Components/Forms/Select.php
+++ b/app/Views/Components/Forms/Select.php
@@ -15,17 +15,17 @@ class Select extends FormComponent
 
     public function setOptions(string $value): void
     {
-        // dd(json_decode(html_entity_decode(html_entity_decode($value)), true));
         $this->options = json_decode(html_entity_decode($value), true);
     }
 
     public function render(): string
     {
         $defaultAttributes = [
-            'data-class' => 'border-3 rounded-lg ' . $this->class,
+            'class' => 'focus:border-black focus:ring-2 focus:ring-pine-500 focus:ring-offset-2 focus:ring-offset-pine-100 border-3 rounded-lg border-black ' . $this->class,
+            'data-class' => $this->class,
         ];
-        $extra = array_merge($defaultAttributes, $this->attributes);
+        $extra = array_merge($this->attributes, $defaultAttributes);
 
-        return form_dropdown($this->name, $this->options, $this->selected !== '' ? [$this->selected] : [], $extra);
+        return form_dropdown($this->name, $this->options, old($this->name, $this->selected !== '' ? [$this->selected] : []), $extra);
     }
 }
diff --git a/app/Views/Components/IconButton.php b/app/Views/Components/IconButton.php
index 574cc817a0..d11199ae19 100644
--- a/app/Views/Components/IconButton.php
+++ b/app/Views/Components/IconButton.php
@@ -12,10 +12,20 @@ class IconButton extends Component
 
     public function render(): string
     {
-        $attributes = stringify_attributes($this->attributes);
+        $attributes = [
+            'isSquared' => 'true',
+            'title' => $this->slot,
+            'data-toggle' => 'tooltip',
+            'data-placement' => 'bottom',
+        ];
 
-        return <<<HTML
-            <Button isSquared="true" title="{$this->slot}" data-toggle="tooltip" data-placement="bottom" {$attributes}><Icon glyph="{$this->glyph}" /></Button>
-        HTML;
+        $attributes = array_merge($attributes, $this->attributes);
+
+        $attributes['slot'] = icon($this->glyph);
+
+        unset($attributes['glyph']);
+
+        $iconButton = new Button($attributes);
+        return $iconButton->render();
     }
 }
diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php
index 8716e64199..026a625528 100644
--- a/modules/Admin/Controllers/EpisodeController.php
+++ b/modules/Admin/Controllers/EpisodeController.php
@@ -722,6 +722,7 @@ class EpisodeController extends BaseController
                 "soundbites.{$soundbite_id}.duration" => 'required|decimal|greater_than_equal_to[0]',
             ];
         }
+
         if (! $this->validate($rules)) {
             return redirect()
                 ->back()
@@ -730,34 +731,33 @@ class EpisodeController extends BaseController
         }
 
         foreach ($soundbites as $soundbite_id => $soundbite) {
-            if ((int) $soundbite['start_time'] < (int) $soundbite['duration']) {
-                $data = [
-                    'podcast_id' => $this->podcast->id,
-                    'episode_id' => $this->episode->id,
-                    'start_time' => (float) $soundbite['start_time'],
-                    'duration' => (float) $soundbite['duration'],
-                    'label' => $soundbite['label'],
-                    'updated_by' => user_id(),
+            $data = [
+                'podcast_id' => $this->podcast->id,
+                'episode_id' => $this->episode->id,
+                'start_time' => (float) $soundbite['start_time'],
+                'duration' => (float) $soundbite['duration'],
+                'label' => $soundbite['label'],
+                'updated_by' => user_id(),
+            ];
+            if ($soundbite_id === 0) {
+                $data += [
+                    'created_by' => user_id(),
+                ];
+            } else {
+                $data += [
+                    'id' => $soundbite_id,
                 ];
-                if ($soundbite_id === 0) {
-                    $data += [
-                        'created_by' => user_id(),
-                    ];
-                } else {
-                    $data += [
-                        'id' => $soundbite_id,
-                    ];
-                }
-
-                $soundbiteModel = new SoundbiteModel();
-                if (! $soundbiteModel->save($data)) {
-                    return redirect()
-                        ->back()
-                        ->withInput()
-                        ->with('errors', $soundbiteModel->errors());
-                }
+            }
+
+            $soundbiteModel = new SoundbiteModel();
+            if (! $soundbiteModel->save($data)) {
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('errors', $soundbiteModel->errors());
             }
         }
+
         return redirect()->route('soundbites-edit', [$this->podcast->id, $this->episode->id]);
     }
 
diff --git a/modules/Install/Controllers/InstallController.php b/modules/Install/Controllers/InstallController.php
index 63260eebb9..59bad8c901 100644
--- a/modules/Install/Controllers/InstallController.php
+++ b/modules/Install/Controllers/InstallController.php
@@ -130,7 +130,7 @@ class InstallController extends Controller
             session()
                 ->setFlashdata('error', lang('Install.messages.databaseConnectError'));
 
-            return view('database_config');
+            return $this->databaseConfig();
         }
 
         // migrate if no user has been created
diff --git a/modules/Install/Language/en/Install.php b/modules/Install/Language/en/Install.php
index c70faf69a5..33101373b0 100644
--- a/modules/Install/Language/en/Install.php
+++ b/modules/Install/Language/en/Install.php
@@ -9,6 +9,7 @@ declare(strict_types=1);
  */
 
 return [
+    'title' => 'Castopod installer',
     'manual_config' => 'Manual configuration',
     'manual_config_subtitle' =>
         'Create a `.env` file with your settings and refresh the page to continue installation.',
diff --git a/modules/Install/Language/fr/Install.php b/modules/Install/Language/fr/Install.php
index 64eebae164..38cc18a797 100644
--- a/modules/Install/Language/fr/Install.php
+++ b/modules/Install/Language/fr/Install.php
@@ -9,6 +9,7 @@ declare(strict_types=1);
  */
 
 return [
+    'title' => 'Installeur Castopod',
     'manual_config' => 'Configuration manuelle',
     'manual_config_subtitle' =>
         'Créez un fichier `.env` qui contient tous vos paramètres puis rafraichissez la page pour continuer l’installation.',
diff --git a/themes/cp_admin/contributor/list.php b/themes/cp_admin/contributor/list.php
index f0f3c01444..f6e3a6ea99 100644
--- a/themes/cp_admin/contributor/list.php
+++ b/themes/cp_admin/contributor/list.php
@@ -9,10 +9,7 @@
 <?= $this->endSection() ?>
 
 <?= $this->section('headerRight') ?>
-<?= button(lang('Contributor.add'), route_to('contributor-add', $podcast->id), [
-    'variant' => 'accent',
-    'iconLeft' => 'add',
-]) ?>
+<Button uri="<?= route_to('contributor-add', $podcast->id) ?>" variant="accent" iconLeft="add"><?= lang('Contributor.add') ?></Button>
 <?= $this->endSection() ?>
 
 
@@ -35,36 +32,8 @@
         [
             'header' => lang('Common.actions'),
             'cell' => function ($contributor, $podcast) {
-                return button(
-                    lang('Contributor.edit'),
-                    route_to(
-                        'contributor-edit',
-                        $podcast->id,
-                        $contributor->id,
-                    ),
-                    [
-                        'variant' => 'info',
-                        'size' => 'small',
-                    ],
-                    [
-                        'class' => 'mr-2',
-                    ],
-                ) .
-                    button(
-                        lang('Contributor.remove'),
-                        route_to(
-                            'contributor-remove',
-                            $podcast->id,
-                            $contributor->id,
-                        ),
-                        [
-                            'variant' => 'danger',
-                            'size' => 'small',
-                        ],
-                        [
-                            'class' => 'mr-2',
-                        ],
-                    );
+                return '<Button uri="' . route_to('contributor-edit', $podcast->id, $contributor->id) . '" variant="info" size="small">' . lang('Contributor.edit') . '</Button>' .
+                '<Button uri="' . route_to('contributor-remove', $podcast->id, $contributor->id) . '" variant="danger" size="small">' . lang('Contributor.remove') . '</Button>';
             },
         ],
     ],
diff --git a/themes/cp_admin/episode/create.php b/themes/cp_admin/episode/create.php
index 34b3e04ca3..d79993b12a 100644
--- a/themes/cp_admin/episode/create.php
+++ b/themes/cp_admin/episode/create.php
@@ -22,7 +22,7 @@
 <Forms.Field
     name="audio_file"
     label="<?= lang('Episode.form.audio_file') ?>"
-    hintText="<?= lang('Episode.form.audio_file_hint') ?>"
+    hint="<?= lang('Episode.form.audio_file_hint') ?>"
     type="file"
     accept=".mp3,.m4a"
     required="true" />
@@ -30,15 +30,15 @@
 <Forms.Field
     name="image"
     label="<?= lang('Episode.form.image') ?>"
-    hintText="<?= lang('Episode.form.image_hint') ?>"
-    helperText="<?= lang('Common.forms.image_size_hint', ) ?>"
+    hint="<?= lang('Episode.form.image_hint') ?>"
+    helper="<?= lang('Common.forms.image_size_hint', ) ?>"
     type="file"
     accept=".jpg,.jpeg,.png" />
 
 <Forms.Field
     name="title"
     label="<?= lang('Episode.form.title') ?>"
-    hintText="<?= lang('Episode.form.title_hint') ?>"
+    hint="<?= lang('Episode.form.title_hint') ?>"
     required="true"
     data-slugify="title" />
 
@@ -120,7 +120,7 @@
     as="MarkdownEditor"
     name="description_footer"
     label="<?= lang('Episode.form.description_footer') ?>"
-    hintText="<?= lang('Episode.form.description_footer_hint') ?>" />
+    hint="<?= lang('Episode.form.description_footer_hint') ?>" />
 
 </Forms.Section>
 
diff --git a/themes/cp_admin/episode/edit.php b/themes/cp_admin/episode/edit.php
index 87e4cd1886..e82e2aee2c 100644
--- a/themes/cp_admin/episode/edit.php
+++ b/themes/cp_admin/episode/edit.php
@@ -26,22 +26,22 @@
 <Forms.Field
     name="audio_file"
     label="<?= lang('Episode.form.audio_file') ?>"
-    hintText="<?= lang('Episode.form.audio_file_hint') ?>"
+    hint="<?= lang('Episode.form.audio_file_hint') ?>"
     type="file"
     accept=".mp3,.m4a" />
 
 <Forms.Field
     name="image"
     label="<?= lang('Episode.form.image') ?>"
-    hintText="<?= lang('Episode.form.image_hint') ?>"
-    helperText="<?= lang('Common.forms.image_size_hint', ) ?>"
+    hint="<?= lang('Episode.form.image_hint') ?>"
+    helper="<?= lang('Common.forms.image_size_hint', ) ?>"
     type="file"
     accept=".jpg,.jpeg,.png" />
 
 <Forms.Field
     name="title"
     label="<?= lang('Episode.form.title') ?>"
-    hintText="<?= lang('Episode.form.title_hint') ?>"
+    hint="<?= lang('Episode.form.title_hint') ?>"
     value="<?= $episode->title ?>"
     required="true"
     data-slugify="title" />
@@ -127,7 +127,7 @@
     as="MarkdownEditor"
     name="description_footer"
     label="<?= lang('Episode.form.description_footer') ?>"
-    hintText="<?= lang('Episode.form.description_footer_hint') ?>"
+    hint="<?= lang('Episode.form.description_footer_hint') ?>"
     value="<?= $podcast->episode_description_footer_markdown ?? '' ?>" />
 
 </Forms.Section>
diff --git a/themes/cp_admin/episode/list.php b/themes/cp_admin/episode/list.php
index 3048ccfbfa..ec7b2c4dbd 100644
--- a/themes/cp_admin/episode/list.php
+++ b/themes/cp_admin/episode/list.php
@@ -9,10 +9,7 @@
 <?= $this->endSection() ?>
 
 <?= $this->section('headerRight') ?>
-<?= button(lang('Episode.create'), route_to('episode-create', $podcast->id), [
-    'variant' => 'accent',
-    'iconLeft' => 'add',
-]) ?>
+<Button uri="<?= route_to('episode-create', $podcast->id) ?>" variant="accent" iconLeft="add"><?= lang('Episode.create') ?></Button>
 <?= $this->endSection() ?>
 
 
diff --git a/themes/cp_admin/episode/persons.php b/themes/cp_admin/episode/persons.php
index 204f322a9f..2b64fe3c81 100644
--- a/themes/cp_admin/episode/persons.php
+++ b/themes/cp_admin/episode/persons.php
@@ -50,19 +50,7 @@
         [
             'header' => lang('Common.actions'),
             'cell' => function ($person): string {
-                return button(
-                    lang('Person.episode_form.remove'),
-                    route_to(
-                        'episode-person-remove',
-                        $person->podcast_id,
-                        $person->episode_id,
-                        $person->id,
-                    ),
-                    [
-                        'variant' => 'danger',
-                        'size' => 'small',
-                    ],
-                );
+                return '<Button uri="' . route_to('episode-person-remove', $person->podcast_id, $person->episode_id, $person->id) . '" variant="danger" size="small">' . lang('Person.episode_form.remove') . '</Button>';
             },
         ],
     ],
@@ -82,7 +70,7 @@
     id="persons"
     name="persons[]"
     label="<?= lang('Person.episode_form.persons') ?>"
-    hintText="<?= lang('Person.episode_form.persons_hint') ?>"
+    hint="<?= lang('Person.episode_form.persons_hint') ?>"
     options="<?= htmlspecialchars(json_encode($personOptions)) ?>"
     selected="<?= htmlspecialchars(json_encode(old('persons', []))) ?>"
     required="true"
@@ -93,7 +81,7 @@
     id="roles"
     name="roles[]"
     label="<?= lang('Person.episode_form.roles') ?>"
-    hintText="<?= lang('Person.episode_form.roles_hint') ?>"
+    hint="<?= lang('Person.episode_form.roles_hint') ?>"
     options="<?= htmlspecialchars(json_encode($taxonomyOptions)) ?>"
     selected="<?= htmlspecialchars(json_encode(old('roles', []))) ?>"
     required="true"
diff --git a/themes/cp_admin/episode/publish.php b/themes/cp_admin/episode/publish.php
index c0c330ba58..ab4f51f8af 100644
--- a/themes/cp_admin/episode/publish.php
+++ b/themes/cp_admin/episode/publish.php
@@ -90,7 +90,7 @@
                 as="DatetimePicker"
                 name="scheduled_publication_date"
                 label="<?= lang('Episode.publish_form.scheduled_publication_date') ?>"
-                hintText="<?= lang('Episode.publish_form.scheduled_publication_date_hint') ?>"
+                hint="<?= lang('Episode.publish_form.scheduled_publication_date_hint') ?>"
                 value="<?= $episode->published_at ?>"
             />
         </div>
diff --git a/themes/cp_admin/episode/publish_edit.php b/themes/cp_admin/episode/publish_edit.php
index 75cba86499..1c872055c5 100644
--- a/themes/cp_admin/episode/publish_edit.php
+++ b/themes/cp_admin/episode/publish_edit.php
@@ -94,7 +94,7 @@
                 as="DatetimePicker"
                 name="scheduled_publication_date"
                 label="<?= lang('Episode.publish_form.scheduled_publication_date') ?>"
-                hintText="<?= lang('Episode.publish_form.scheduled_publication_date_hint') ?>"
+                hint="<?= lang('Episode.publish_form.scheduled_publication_date_hint') ?>"
                 value="<?= $episode->published_at ?>"
             />
         </div>
diff --git a/themes/cp_admin/episode/soundbites.php b/themes/cp_admin/episode/soundbites.php
index a09a950afc..d57db65eb8 100644
--- a/themes/cp_admin/episode/soundbites.php
+++ b/themes/cp_admin/episode/soundbites.php
@@ -11,215 +11,63 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open(
-    route_to('episode-soundbites-edit', $podcast->id, $episode->id),
-    [
-        'method' => 'post',
-        'class' => 'flex flex-col',
-    ],
-) ?>
+<form action="<?= route_to('episode-soundbites-edit', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col">
 <?= csrf_field() ?>
 
-<?= form_section(
-    lang('Episode.soundbites_form.info_section_title'),
-    lang('Episode.soundbites_form.info_section_subtitle'),
-) ?>
-
-    <table class="w-full table-fixed">
-        <thead>
-        <tr>
-            <th class="w-3/12 px-1 py-2">       
-            <?= form_label(
-    lang('Episode.soundbites_form.start_time'),
-    'start_time',
-    [],
-    lang('Episode.soundbites_form.start_time_hint'),
-) ?></th>
-            <th class="w-3/12 px-1 py-2"><?= form_label(
-    lang('Episode.soundbites_form.duration'),
-    'duration',
-    [],
-    lang('Episode.soundbites_form.duration_hint'),
-) ?></th>
-            <th class="w-7/12 px-1 py-2"><?= form_label(
-    lang('Episode.soundbites_form.label'),
-    'label',
-    [],
-    lang('Episode.soundbites_form.label_hint'),
-    true,
-) ?></th>
-            <th class="w-1/12 px-1 py-2"></th>
-            </tr>
-        </thead>
-        <tbody>
-        <?php foreach ($episode->soundbites as $soundbite): ?>
-        <tr>
-            <td class="px-1 py-2 font-medium bg-white border border-light-blue-500"><?= form_input(
-    [
-        'type' => 'number',
-        'min' => 0,
-        'max' => $episode->audio_file_duration,
-        'step' => 'any',
-        'id' => "soundbites[{$soundbite->id}][start_time]",
-        'name' => "soundbites[{$soundbite->id}][start_time]",
-        'class' => 'form-input w-full border-none text-center',
-        'value' => $soundbite->start_time,
-        'data-type' => 'soundbite-field',
-        'data-field-type' => 'start-time',
-        'data-soundbite-id' => $soundbite->id,
-        'required' => 'required',
-    ],
-) ?></td>
-            <td class="px-1 py-2 font-medium bg-white border border-light-blue-500"><?= form_input(
-    [
-        'type' => 'number',
-        'min' => 0,
-        'max' => $episode->audio_file_duration,
-        'step' => 'any',
-        'id' => "soundbites[{$soundbite->id}][duration]",
-        'name' => "soundbites[{$soundbite->id}][duration]",
-        'class' => 'form-input w-full border-none text-center',
-        'value' => $soundbite->duration,
-        'data-type' => 'soundbite-field',
-        'data-field-type' => 'duration',
-        'data-soundbite-id' => $soundbite->id,
-        'required' => 'required',
-    ],
-) ?></td>
-            <td class="px-1 py-2 font-medium bg-white border border-light-blue-500"><?= form_input(
-    [
-        'id' => "soundbites[{$soundbite->id}][label]",
-        'name' => "soundbites[{$soundbite->id}][label]",
-        'class' => 'form-input w-full border-none',
-        'value' => $soundbite->label,
-    ],
-) ?></td>
-            <td class="px-4 py-2"><?= icon_button(
-    'play',
-    lang('Episode.soundbites_form.play'),
-    '',
-    [
-        'variant' => 'primary',
-    ],
-    [
-        'class' => 'mb-1 mr-1',
-        'data-type' => 'play-soundbite',
-        'data-soundbite-id' => $soundbite->id,
-        'data-soundbite-start-time' => $soundbite->start_time,
-        'data-soundbite-duration' => $soundbite->duration,
-    ],
-) ?>
-            <?= icon_button(
-    'delete-bin',
-    lang('Episode.soundbites_form.delete'),
-    route_to(
-        'soundbite-delete',
-        $podcast->id,
-        $episode->id,
-        $soundbite->id,
-    ),
-    [
-        'variant' => 'danger',
-    ],
-    [],
-) ?>    
-            </td>
-        </tr>
-        <?php endforeach; ?>
-        <tr>
-        <td class="px-1 py-4 font-medium bg-white border border-light-blue-500"><?= form_input(
-    [
-        'type' => 'number',
-        'min' => 0,
-        'max' => $episode->audio_file_duration,
-        'step' => 'any',
-        'id' => 'soundbites[0][start_time]',
-        'name' => 'soundbites[0][start_time]',
-        'class' => 'form-input w-full border-none text-center',
-        'value' => old('start_time'),
-        'data-soundbite-id' => '0',
-        'data-type' => 'soundbite-field',
-        'data-field-type' => 'start-time',
-    ],
-) ?></td>
-        <td class="px-1 py-4 font-medium bg-white border border-light-blue-500"><?= form_input(
-    [
-        'type' => 'number',
-        'min' => 0,
-        'max' => $episode->audio_file_duration,
-        'step' => 'any',
-        'id' => 'soundbites[0][duration]',
-        'name' => 'soundbites[0][duration]',
-        'class' => 'form-input w-full border-none text-center',
-        'value' => old('duration'),
-        'data-soundbite-id' => '0',
-        'data-type' => 'soundbite-field',
-        'data-field-type' => 'duration',
-    ],
-) ?></td>
-        <td class="px-1 py-4 font-medium bg-white border border-light-blue-500"><?= form_input(
-    [
-        'id' => 'soundbites[0][label]',
-        'name' => 'soundbites[0][label]',
-        'class' => 'form-input w-full border-none',
-        'value' => old('label'),
-    ],
-) ?></td>
-        <td class="px-4 py-2"><?= icon_button(
-    'play',
-    lang('Episode.soundbites_form.play'),
-    '',
-    [
-        'variant' => 'primary',
-    ],
-    [
-        'data-type' => 'play-soundbite',
-        'data-soundbite-id' => 0,
-        'data-soundbite-start-time' => 0,
-        'data-soundbite-duration' => 0,
-    ],
-) ?>
-            
-                    
-        </td>
-        </tr>
-        <tr><td colspan="3">
-            <audio controls preload="auto" class="w-full">
-                <source src="<?= $episode->audio_file_url ?>" type="<?= $episode->audio_file_mimetype ?>">
-        Your browser does not support the audio tag.
-            </audio>
-        </td><td class="px-4 py-2"><?= icon_button(
-    'timer',
-    lang('Episode.soundbites_form.bookmark'),
-    '',
-    [
-        'variant' => 'info',
-    ],
-    [
-        'data-type' => 'get-soundbite',
-        'data-start-time-field-name' => 'soundbites[0][start_time]',
-        'data-duration-field-name' => 'soundbites[0][duration]',
-    ],
-) ?></td></tr>
-    </tbody>
-    </table>
-
-    
-<?= form_section_close() ?>
-
-<?= button(
-    lang('Episode.soundbites_form.submit_edit'),
-    '',
-    [
-        'variant' => 'primary',
-    ],
-    [
-        'type' => 'submit',
-        'class' => 'self-end',
-    ],
-) ?>
-
-<?= form_close() ?>
+<Forms.Section
+    title="<?= lang('Episode.soundbites_form.info_section_title') ?>"
+    subtitle="<?= lang('Episode.soundbites_form.info_section_subtitle') ?>" >
+
+    <?php
+    $table = new \CodeIgniter\View\Table();
+
+    $table->setHeading(
+        lang('Episode.soundbites_form.start_time') . hint_tooltip(lang('Episode.soundbites_form.start_time_hint')),
+        lang('Episode.soundbites_form.duration') . hint_tooltip(lang('Episode.soundbites_form.duration_hint')),
+        lang('Episode.soundbites_form.label') . hint_tooltip(lang('Episode.soundbites_form.label_hint')),
+        '',
+        ''
+    );
+
+    foreach ($episode->soundbites as $soundbite) {
+        $table->addRow(
+            "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio_file_duration}' name='soundbites[{$soundbite->id}][start_time]' value='{$soundbite->start_time}' data-type='soundbite-field' data-field-type='start_time' required='true' />",
+            "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio_file_duration}' name='soundbites[{$soundbite->id}][duration]' value='{$soundbite->duration}' data-type='soundbite-field' data-field-type='duration' required='true' />",
+            "<Forms.Input class='flex-1' name='soundbites[{$soundbite->id}][label]' value='{$soundbite->label}' />",
+            "<IconButton variant='primary' glyph='play' data-type='play-soundbite' data-soundbite-id='{$soundbite->id}'>" . lang('Episode.soundbites_form.play') . '</IconButton>',
+            '<IconButton uri=' . route_to(
+                'soundbite-delete',
+                $podcast->id,
+                $episode->id,
+                $soundbite->id,
+            ) . " variant='danger' glyph='delete-bin'>" . lang('Episode.soundbites_form.delete') . '</IconButton>'
+        );
+    }
+
+    $table->addRow(
+        "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio_file_duration}' name='soundbites[0][start_time]' data-type='soundbite-field' data-field-type='start_time' />",
+        "<Forms.Input class='w-24' type='number' step='any' min='0' max='{$episode->audio_file_duration}' name='soundbites[0][duration]' data-type='soundbite-field' data-field-type='duration' />",
+        "<Forms.Input class='flex-1' name='soundbites[0][label]' />",
+        "<IconButton variant='primary' glyph='play' data-type='play-soundbite' data-soundbite-id='0'>" . lang('Episode.soundbites_form.play') . '</IconButton>',
+    );
+
+    echo $table->generate();
+
+    ?>
+
+    <div class="flex items-center gap-x-2">
+        <audio controls preload="auto" class="flex-1 w-full">
+            <source src="<?= $episode->audio_file_url ?>" type="<?= $episode->audio_file_mimetype ?>">
+            Your browser does not support the audio tag.
+        </audio>
+        <IconButton glyph="timer" variant="info" data-type="get-soundbite" data-start-time-field-name="soundbites[0][start_time]" data-duration-field-name="soundbites[0][duration]" ><?= lang('Episode.soundbites_form.bookmark') ?></IconButton>
+    </div>
+
+</Forms.Section>
+
+<Button variant="primary" type="submit" class="self-end"><?= lang('Episode.soundbites_form.submit_edit') ?></Button>
+
+</form>
 
 
 <?= $this->endSection() ?>
diff --git a/themes/cp_admin/fediverse/blocked_actors.php b/themes/cp_admin/fediverse/blocked_actors.php
index beffc5d263..754cacce3a 100644
--- a/themes/cp_admin/fediverse/blocked_actors.php
+++ b/themes/cp_admin/fediverse/blocked_actors.php
@@ -12,7 +12,7 @@
 <?= $this->section('content') ?>
 
 <form action="<?= route_to('fediverse-attempt-block-actor') ?>" method="POST" class="flex flex-col max-w-md">
-    <Forms.Field name="handle" label="<?= lang('Fediverse.block_lists_form.handle') ?>" hintText="<?= lang('Fediverse.block_lists_form.handle_hint') ?>" />
+    <Forms.Field name="handle" label="<?= lang('Fediverse.block_lists_form.handle') ?>" hint="<?= lang('Fediverse.block_lists_form.handle_hint') ?>" />
     <Button variant="primary" type="submit" class="self-end"><?= lang('Fediverse.block_lists_form.submit') ?></Button>
 </form>
 
@@ -34,21 +34,7 @@
                     $blockedActor->id .
                     '" />' .
                     csrf_field() .
-                    button(
-                        lang('Fediverse.list.unblock'),
-                        route_to(
-                            'fediverse-unblock-actor',
-                            $blockedActor->username,
-                        ),
-                        [
-                            'variant' => 'info',
-                            'size' => 'small',
-                        ],
-                        [
-                            'class' => 'mr-2',
-                            'type' => 'submit',
-                        ],
-                    ) .
+                    '<Button uri="' . route_to('fediverse-unblock-actor', $blockedActor->username) . '" variant="info" size="small" type="submit">' . lang('Fediverse.list.unblock') . '</Button>' .
                     '</form>';
             },
         ],
diff --git a/themes/cp_admin/fediverse/blocked_domains.php b/themes/cp_admin/fediverse/blocked_domains.php
index fe9bc509bf..65d10ea994 100644
--- a/themes/cp_admin/fediverse/blocked_domains.php
+++ b/themes/cp_admin/fediverse/blocked_domains.php
@@ -34,21 +34,7 @@
                     $blockedDomain->name .
                     '" />' .
                     csrf_field() .
-                    button(
-                        lang('Fediverse.list.unblock'),
-                        route_to(
-                            'fediverse-unblock-domain',
-                            $blockedDomain->name,
-                        ),
-                        [
-                            'variant' => 'info',
-                            'size' => 'small',
-                        ],
-                        [
-                            'class' => 'mr-2',
-                            'type' => 'submit',
-                        ],
-                    ) .
+                    '<Button uri="' . route_to('fediverse-unblock-domain', $blockedDomain->name) . '" variant="info" size="small" type="submit">' . lang('Fediverse.list.unblock') . '</Button>' .
                     '</form>';
             },
         ],
diff --git a/themes/cp_admin/page/create.php b/themes/cp_admin/page/create.php
index d816629bf9..0a13133b9d 100644
--- a/themes/cp_admin/page/create.php
+++ b/themes/cp_admin/page/create.php
@@ -11,9 +11,6 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open(route_to('page-create'), [
-    'class' => 'flex flex-col max-w-3xl',
-]) ?>
 <form action="<?= route_to('page-create') ?>" method="POST" class="flex flex-col max-w-3xl gap-y-4">
 <?= csrf_field() ?>
 
diff --git a/themes/cp_admin/page/list.php b/themes/cp_admin/page/list.php
index 60cd46fb0a..8925a76997 100644
--- a/themes/cp_admin/page/list.php
+++ b/themes/cp_admin/page/list.php
@@ -9,10 +9,7 @@
 <?= $this->endSection() ?>
 
 <?= $this->section('headerRight') ?>
-<?= button(lang('Page.create'), route_to('page-create'), [
-    'variant' => 'accent',
-    'iconLeft' => 'add',
-]) ?>
+<Button uri="<?= route_to('page-create') ?>" variant="accent" iconLeft="add"><?= lang('Page.create') ?></Button>
 <?= $this->endSection() ?>
 
 
@@ -33,36 +30,9 @@
         [
             'header' => lang('Common.actions'),
             'cell' => function ($page) {
-                return button(
-                    lang('Page.go_to_page'),
-                    route_to('page', $page->slug),
-                    [
-                        'variant' => 'secondary',
-                        'size' => 'small',
-                    ],
-                    [
-                        'class' => 'mr-2',
-                    ],
-                ) .
-                    button(
-                        lang('Page.edit'),
-                        route_to('page-edit', $page->id),
-                        [
-                            'variant' => 'info',
-                            'size' => 'small',
-                        ],
-                        [
-                            'class' => 'mr-2',
-                        ],
-                    ) .
-                    button(
-                        lang('Page.delete'),
-                        route_to('page-delete', $page->id),
-                        [
-                            'variant' => 'danger',
-                            'size' => 'small',
-                        ],
-                    );
+                return '<Button uri="' . route_to('page', $page->slug) . '" variant="secondary" size="small">' . lang('Page.go_to_page') . '</Button>' .
+                '<Button uri="' . route_to('page-edit', $page->id) . '" variant="info" size="small">' . lang('Page.edit') . '</Button>' .
+                '<Button uri="' . route_to('page-delete', $page->id) . '" variant="danger" size="small">' . lang('Page.delete') . '</Button>';
             },
         ],
     ],
diff --git a/themes/cp_admin/page/view.php b/themes/cp_admin/page/view.php
index db28d587e8..97d8bd3523 100644
--- a/themes/cp_admin/page/view.php
+++ b/themes/cp_admin/page/view.php
@@ -9,10 +9,7 @@
 <?= $this->endSection() ?>
 
 <?= $this->section('headerRight') ?>
-<?= button(lang('Page.edit'), route_to('page-edit', $page->id), [
-    'variant' => 'accent',
-    'iconLeft' => 'add',
-]) ?>
+<Button variant="accent" uri="<?= route_to('page-edit', $page->id) ?>" iconLeft="add"><?= lang('Page.edit') ?></Button>
 <?= $this->endSection() ?>
 
 <?= $this->section('content') ?>
diff --git a/themes/cp_admin/person/create.php b/themes/cp_admin/person/create.php
index b33da6e230..dc634870c5 100644
--- a/themes/cp_admin/person/create.php
+++ b/themes/cp_admin/person/create.php
@@ -17,7 +17,7 @@
 <Forms.Field
     name="image"
     label="<?= lang('Person.form.image') ?>"
-    helperText="<?= lang('Person.form.image_size_hint') ?>"
+    helper="<?= lang('Person.form.image_size_hint') ?>"
     type="file"
     required="true"
     accept=".jpg,.jpeg,.png" />
@@ -25,19 +25,19 @@
 <Forms.Field
     name="full_name"
     label="<?= lang('Person.form.full_name') ?>"
-    hintText="<?= lang('Person.form.full_name_hint') ?>"
+    hint="<?= lang('Person.form.full_name_hint') ?>"
     required="true"
     data-slugify="title" />
 
 <Forms.Field
     name="unique_name"
     label="<?= lang('Person.form.unique_name') ?>"
-    hintText="<?= lang('Person.form.unique_name_hint') ?>"
+    hint="<?= lang('Person.form.unique_name_hint') ?>"
     required="true" />
 <Forms.Field
     name="information_url"
     label="<?= lang('Person.form.information_url') ?>"
-    hintText="<?= lang('Person.form.information_url_hint') ?>" />
+    hint="<?= lang('Person.form.information_url_hint') ?>" />
 
 <Button variant="primary" class="self-end" type="submit"><?= lang('Person.form.submit_create') ?></Button>
 
diff --git a/themes/cp_admin/person/edit.php b/themes/cp_admin/person/edit.php
index 72b41e6c03..3288332a4e 100644
--- a/themes/cp_admin/person/edit.php
+++ b/themes/cp_admin/person/edit.php
@@ -19,7 +19,7 @@
 <Forms.Field
     name="image"
     label="<?= lang('Person.form.image') ?>"
-    helperText="<?= lang('Person.form.image_size_hint') ?>"
+    helper="<?= lang('Person.form.image_size_hint') ?>"
     type="file"
     accept=".jpg,.jpeg,.png" />
 
@@ -27,7 +27,7 @@
     name="full_name"
     value="<?= $person->full_name ?>"
     label="<?= lang('Person.form.full_name') ?>"
-    hintText="<?= lang('Person.form.full_name_hint') ?>"
+    hint="<?= lang('Person.form.full_name_hint') ?>"
     required="true"
     data-slugify="title" />
 
@@ -35,12 +35,12 @@
     name="unique_name"
     value="<?= $person->unique_name ?>"
     label="<?= lang('Person.form.unique_name') ?>"
-    hintText="<?= lang('Person.form.unique_name_hint') ?>"
+    hint="<?= lang('Person.form.unique_name_hint') ?>"
     required="true" />
 <Forms.Field
     name="information_url"
     label="<?= lang('Person.form.information_url') ?>"
-    hintText="<?= lang('Person.form.information_url_hint') ?>"
+    hint="<?= lang('Person.form.information_url_hint') ?>"
     value="<?= $person->information_url ?>" />
 
 <Button variant="primary" class="self-end" type="submit"><?= lang('Person.form.submit_edit') ?></Button>
diff --git a/themes/cp_admin/person/list.php b/themes/cp_admin/person/list.php
index e3761f33de..24fbb5e3d1 100644
--- a/themes/cp_admin/person/list.php
+++ b/themes/cp_admin/person/list.php
@@ -9,17 +9,7 @@
 <?= $this->endSection() ?>
 
 <?= $this->section('headerRight') ?>
-<?= button(
-    lang('Person.create'),
-    route_to('person-create'),
-    [
-        'variant' => 'primary',
-        'iconLeft' => 'add',
-    ],
-    [
-        'class' => 'mr-2',
-    ],
-) ?>
+<Button uri="<?= route_to('person-create') ?>" variant="primary" iconLeft="add"><?= lang('Person.create') ?></Button>
 <?= $this->endSection() ?>
 
 <?= $this->section('content') ?>
diff --git a/themes/cp_admin/person/view.php b/themes/cp_admin/person/view.php
index 859b0db16f..395b6d4416 100644
--- a/themes/cp_admin/person/view.php
+++ b/themes/cp_admin/person/view.php
@@ -10,17 +10,7 @@
 <?= $this->endSection() ?>
 
 <?= $this->section('headerRight') ?>
-<?= button(
-    lang('Person.edit'),
-    route_to('person-edit', $person->id),
-    [
-        'variant' => 'secondary',
-        'iconLeft' => 'edit',
-    ],
-    [
-        'class' => 'mr-2',
-    ],
-) ?>
+<Button uri="<?= route_to('person-edit', $person->id) ?>" variant="secondary" iconLeft="edit"><?= lang('Person.edit') ?></Button>
 <?= $this->endSection() ?>
 
 <?= $this->section('content') ?>
diff --git a/themes/cp_admin/podcast/create.php b/themes/cp_admin/podcast/create.php
index 3629681520..0ef31938e2 100644
--- a/themes/cp_admin/podcast/create.php
+++ b/themes/cp_admin/podcast/create.php
@@ -25,7 +25,7 @@
 <Forms.Field
     name="image"
     label="<?= lang('Podcast.form.image') ?>"
-    helperText="<?= lang('Common.forms.image_size_hint') ?>"
+    helper="<?= lang('Common.forms.image_size_hint') ?>"
     type="file"
     required="true"
     accept=".jpg,.jpeg,.png" />
@@ -121,20 +121,20 @@
 <Forms.Field
     name="owner_name"
     label="<?= lang('Podcast.form.owner_name') ?>"
-    hintText="<?= lang('Podcast.form.owner_name_hint') ?>"
+    hint="<?= lang('Podcast.form.owner_name_hint') ?>"
     required="true" />
 
 <Forms.Field
     name="owner_email"
     type="email"
     label="<?= lang('Podcast.form.owner_email') ?>"
-    hintText="<?= lang('Podcast.form.owner_email_hint') ?>"
+    hint="<?= lang('Podcast.form.owner_email_hint') ?>"
     required="true" />
 
 <Forms.Field
     name="publisher"
     label="<?= lang('Podcast.form.publisher') ?>"
-    hintText="<?= lang('Podcast.form.publisher_hint') ?>" />
+    hint="<?= lang('Podcast.form.publisher_hint') ?>" />
 
 <Forms.Field
     name="copyright"
@@ -150,7 +150,7 @@
 <Forms.Field
     name="location_name"
     label="<?= lang('Podcast.form.location_name') ?>"
-    hintText="<?= lang('Podcast.form.location_name_hint') ?>" />
+    hint="<?= lang('Podcast.form.location_name_hint') ?>" />
 
 </Forms.Section>
 
@@ -162,7 +162,7 @@
 <Forms.Field
     name="payment_pointer"
     label="<?= lang('Podcast.form.payment_pointer') ?>"
-    hintText="<?= lang('Podcast.form.payment_pointer_hint') ?>" />
+    hint="<?= lang('Podcast.form.payment_pointer_hint') ?>" />
 
 <fieldset class="flex flex-col items-start p-4 bg-gray-100 rounded">
     <Heading tagName="legend" class="float-left" size="small"><?= lang('Podcast.form.partnership') ?></Heading>
@@ -192,7 +192,7 @@
     as="XMLEditor"
     name="custom_rss"
     label="<?= lang('Podcast.form.custom_rss') ?>"
-    hintText="<?= lang('Podcast.form.custom_rss_hint') ?>" />
+    hint="<?= lang('Podcast.form.custom_rss_hint') ?>" />
 
 </Forms.Section>
 
diff --git a/themes/cp_admin/podcast/edit.php b/themes/cp_admin/podcast/edit.php
index ddfb2aecde..8809d9b5d8 100644
--- a/themes/cp_admin/podcast/edit.php
+++ b/themes/cp_admin/podcast/edit.php
@@ -29,14 +29,14 @@
 <Forms.Field
     name="image"
     label="<?= lang('Podcast.form.image') ?>"
-    helperText="<?= lang('Common.forms.image_size_hint') ?>"
+    helper="<?= lang('Common.forms.image_size_hint') ?>"
     type="file"
     accept=".jpg,.jpeg,.png" />
 
 <Forms.Field
     name="title"
     label="<?= lang('Podcast.form.title') ?>"
-    helperText="<?= $podcast->link ?>"
+    helper="<?= $podcast->link ?>"
     value="<?= $podcast->title ?>"
     required="true" />
 
@@ -74,22 +74,22 @@
         name="language"
         label="<?= lang('Podcast.form.language') ?>"
         selected="<?= $podcast->language_code ?>"
-        required="true"
-        options="<?= esc(json_encode($languageOptions)) ?>" />
+        options="<?= esc(json_encode($languageOptions)) ?>"
+        required="true" />
 
     <Forms.Field
         as="Select"
         name="category"
         label="<?= lang('Podcast.form.category') ?>"
         selected="<?= $podcast->category_id ?>"
-        required="true"
-        options="<?= esc(json_encode($categoryOptions)) ?>" />
+        options="<?= esc(json_encode($categoryOptions)) ?>"
+        required="true" />
 
     <Forms.Field
         as="MultiSelect"
         name="other_categories[]"
         label="<?= lang('Podcast.form.other_categories') ?>"
-        selected="<?= json_encode(old('other_categories', $podcast->other_categories_ids)) ?>"
+        selected="<?= json_encode($podcast->other_categories_ids) ?>"
         data-max-item-count="2"
         options="<?= esc(json_encode($categoryOptions)) ?>" />
 
@@ -122,7 +122,7 @@
     name="owner_name"
     label="<?= lang('Podcast.form.owner_name') ?>"
     value="<?= $podcast->owner_name ?>"
-    hintText="<?= lang('Podcast.form.owner_name_hint') ?>"
+    hint="<?= lang('Podcast.form.owner_name_hint') ?>"
     required="true" />
 
 <Forms.Field
@@ -130,14 +130,14 @@
     type="email"
     label="<?= lang('Podcast.form.owner_email') ?>"
     value="<?= $podcast->owner_email ?>"
-    hintText="<?= lang('Podcast.form.owner_email_hint') ?>"
+    hint="<?= lang('Podcast.form.owner_email_hint') ?>"
     required="true" />
 
 <Forms.Field
     name="publisher"
     label="<?= lang('Podcast.form.publisher') ?>"
     value="<?= $podcast->publisher ?>"
-    hintText="<?= lang('Podcast.form.publisher_hint') ?>" />
+    hint="<?= lang('Podcast.form.publisher_hint') ?>" />
 
 <Forms.Field
     name="copyright"
@@ -155,7 +155,7 @@
     name="location_name"
     label="<?= lang('Podcast.form.location_name') ?>"
     value="<?= $podcast->location_name ?>"
-    hintText="<?= lang('Podcast.form.location_name_hint') ?>" />
+    hint="<?= lang('Podcast.form.location_name_hint') ?>" />
 
 </Forms.Section>
 
@@ -168,7 +168,7 @@
     name="payment_pointer"
     label="<?= lang('Podcast.form.payment_pointer') ?>"
     value="<?= $podcast->payment_pointer ?>"
-    hintText="<?= lang('Podcast.form.payment_pointer_hint') ?>" />
+    hint="<?= lang('Podcast.form.payment_pointer_hint') ?>" />
 
 <fieldset class="flex flex-col items-start p-4 bg-gray-100 rounded">
     <Heading tagName="legend" class="float-left" size="small"><?= lang('Podcast.form.partnership') ?></Heading>
@@ -199,7 +199,7 @@
     name="custom_rss"
     label="<?= lang('Podcast.form.custom_rss') ?>"
     value="<?= $podcast->custom_rss_string ?>"
-    hintText="<?= lang('Podcast.form.custom_rss_hint') ?>" />
+    hint="<?= lang('Podcast.form.custom_rss_hint') ?>" />
 
 </Forms.Section>
 
diff --git a/themes/cp_admin/podcast/import.php b/themes/cp_admin/podcast/import.php
index 8306c5f94d..3b96be3e64 100644
--- a/themes/cp_admin/podcast/import.php
+++ b/themes/cp_admin/podcast/import.php
@@ -22,7 +22,7 @@
 <Forms.Field
     name="imported_feed_url"
     label="<?= lang('PodcastImport.imported_feed_url') ?>"
-    hintText="<?= lang('PodcastImport.imported_feed_url_hint') ?>"
+    hint="<?= lang('PodcastImport.imported_feed_url_hint') ?>"
     placeholder="https://…"
     type="url"
     required="true" />
@@ -81,13 +81,13 @@
     name="season_number"
     type="number"
     label="<?= lang('PodcastImport.season_number') ?>"
-    hintText="<?= lang('PodcastImport.season_number_hint') ?>" />
+    hint="<?= lang('PodcastImport.season_number_hint') ?>" />
 
 <Forms.Field
     name="max_episodes"
     type="number"
     label="<?= lang('PodcastImport.max_episodes') ?>"
-    hintText="<?= lang('PodcastImport.max_episodes_hint') ?>" />
+    hint="<?= lang('PodcastImport.max_episodes_hint') ?>" />
 
 </Forms.Section>
 
diff --git a/themes/cp_admin/podcast/list.php b/themes/cp_admin/podcast/list.php
index 9150465817..637b40bcdc 100644
--- a/themes/cp_admin/podcast/list.php
+++ b/themes/cp_admin/podcast/list.php
@@ -9,21 +9,8 @@
 <?= $this->endSection() ?>
 
 <?= $this->section('headerRight') ?>
-<?= button(
-    lang('Podcast.create'),
-    route_to('podcast-create'),
-    [
-        'variant' => 'accent',
-        'iconLeft' => 'add',
-    ],
-    [
-        'class' => 'mr-2',
-    ],
-) ?>
-<?= button(lang('Podcast.import'), route_to('podcast-import'), [
-    'variant' => 'primary',
-    'iconLeft' => 'download',
-]) ?>
+<Button uri="<?= route_to('podcast-create') ?>" variant="accent" iconLeft="add"><?= lang('Podcast.create') ?></Button>
+<Button uri="<?= route_to('podcast-import') ?>" variant="primary" iconLeft="download"><?= lang('Podcast.import') ?></Button>
 <?= $this->endSection() ?>
 
 
diff --git a/themes/cp_admin/podcast/persons.php b/themes/cp_admin/podcast/persons.php
index 44a9f55ca4..2b2f9c3a34 100644
--- a/themes/cp_admin/podcast/persons.php
+++ b/themes/cp_admin/podcast/persons.php
@@ -9,17 +9,7 @@
 <?= $this->endSection() ?>
 
 <?= $this->section('headerRight') ?>
-<?= button(
-    lang('Person.create'),
-    route_to('person-create'),
-    [
-        'variant' => 'primary',
-        'iconLeft' => 'add',
-    ],
-    [
-        'class' => 'mr-2',
-    ],
-) ?>
+<Button uri="<?= route_to('person-create') ?>" variant="primary" iconLeft="add"><?= lang('Person.create') ?></Button>
 <?= $this->endSection() ?>
 
 <?= $this->section('content') ?>
@@ -60,18 +50,7 @@
         [
             'header' => lang('Common.actions'),
             'cell' => function ($person): string {
-                return button(
-                    lang('Person.podcast_form.remove'),
-                    route_to(
-                        'podcast-person-remove',
-                        $person->podcast_id,
-                        $person->id,
-                    ),
-                    [
-                        'variant' => 'danger',
-                        'size' => 'small',
-                    ],
-                );
+                return '<Button uri="' . route_to('podcast-person-remove', $person->podcast_id, $person->id) . '" variant="danger" size="small">' . lang('Person.podcast_form.remove') . '</Button>';
             },
         ],
     ],
diff --git a/themes/cp_admin/podcast/platforms.php b/themes/cp_admin/podcast/platforms.php
index e8d0202098..88313a735e 100644
--- a/themes/cp_admin/podcast/platforms.php
+++ b/themes/cp_admin/podcast/platforms.php
@@ -10,9 +10,7 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open(route_to('platforms-save', $podcast->id, $platformType), [
-    'class' => 'flex flex-col max-w-md',
-]) ?>
+<form action="<?= route_to('platforms-save', $podcast->id, $platformType) ?>" method="POST" class="flex flex-col max-w-md">
 <?= csrf_field() ?>
 
 <?php foreach ($platforms as $platform): ?>
@@ -106,6 +104,6 @@
 
 <Button variant="primary" type="submit" class="self-end"><?= lang('Platforms.submit') ?></Button>
 
-<?= form_close() ?>
+</form>
 
 <?= $this->endSection() ?>
diff --git a/themes/cp_admin/podcast/view.php b/themes/cp_admin/podcast/view.php
index 53bf6dcd30..a9b141e395 100644
--- a/themes/cp_admin/podcast/view.php
+++ b/themes/cp_admin/podcast/view.php
@@ -9,21 +9,8 @@
 <?= $this->endSection() ?>
 
 <?= $this->section('headerRight') ?>
-<?= button(
-    lang('Podcast.edit'),
-    route_to('podcast-edit', $podcast->id),
-    [
-        'variant' => 'primary',
-        'iconLeft' => 'edit',
-    ],
-    [
-        'class' => 'mr-2',
-    ],
-) ?>
-<?= button(lang('Episode.create'), route_to('episode-create', $podcast->id), [
-    'variant' => 'accent',
-    'iconLeft' => 'add',
-]) ?>
+<Button uri="<?= route_to('podcast-edit', $podcast->id) ?>" variant="primary" iconLeft="edit"><?= lang('Podcast.edit') ?></Button>
+<Button uri="<?= route_to('episode-create', $podcast->id) ?>" variant="accent" iconLeft="add"><?= lang('Episode.create') ?></Button>
 <?= $this->endSection() ?>
 
 <?= $this->section('content') ?>
diff --git a/themes/cp_admin/user/create.php b/themes/cp_admin/user/create.php
index ce2af3a580..6f68d2e9d7 100644
--- a/themes/cp_admin/user/create.php
+++ b/themes/cp_admin/user/create.php
@@ -11,49 +11,29 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open(route_to('user-create'), [
-    'class' => 'flex flex-col max-w-sm',
-]) ?>
+<form action="<?= route_to('user-create') ?>" method="POST" class="flex flex-col max-w-sm">
 <?= csrf_field() ?>
 
-<?= form_label(lang('User.form.email'), 'email') ?>
-<?= form_input([
-    'id' => 'email',
-    'name' => 'email',
-    'class' => 'form-input mb-4',
-    'value' => old('email'),
-    'type' => 'email',
-]) ?>
-
-<?= form_label(lang('User.form.username'), 'username') ?>
-<?= form_input([
-    'id' => 'username',
-    'name' => 'username',
-    'class' => 'form-input mb-4',
-    'value' => old('username'),
-]) ?>
-
-<?= form_label(lang('User.form.password'), 'password') ?>
-<?= form_input([
-    'id' => 'password',
-    'name' => 'password',
-    'class' => 'form-input mb-4',
-    'type' => 'password',
-    'autocomplete' => 'new-password',
-]) ?>
-
-<?= button(
-    lang('User.form.submit_create'),
-    '',
-    [
-        'variant' => 'primary',
-    ],
-    [
-        'type' => 'submit',
-        'class' => 'self-end',
-    ],
-) ?>
-
-<?= form_close() ?>
+<Forms.Field
+    name="email"
+    type="email"
+    label="<?= lang('User.form.email') ?>"
+    required="true" />
+
+<Forms.Field
+    name="username"
+    label="<?= lang('User.form.username') ?>"
+    required="true" />
+
+<Forms.Field
+    name="password"
+    type="password"
+    label="<?= lang('User.form.password') ?>"
+    required="true"
+    autocomplete="new-password" />
+
+<Button variant="primary" type="submit" class="self-end"><?= lang('User.form.submit_create') ?></Button>
+
+</form>
 
 <?= $this->endSection() ?>
diff --git a/themes/cp_admin/user/edit.php b/themes/cp_admin/user/edit.php
index b5cb7fe174..1331e2378d 100644
--- a/themes/cp_admin/user/edit.php
+++ b/themes/cp_admin/user/edit.php
@@ -15,26 +15,19 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open(route_to('user-edit', $user->id), [
-    'class' => 'flex flex-col max-w-sm',
-]) ?>
+<form action="<?= route_to('user-edit', $user->id) ?>" method="POST" class="flex flex-col max-w-sm">
 <?= csrf_field() ?>
 
-<?= form_label(lang('User.form.roles'), 'roles') ?>
-<Forms.MultiSelect id="roles" name="roles[]" class="mb-4" required="required" options="<?= htmlspecialchars(json_encode($roleOptions)) ?>" selected="<?= htmlspecialchars(json_encode($user->roles)) ?>"/>
-
-<?= button(
-    lang('User.form.submit_edit'),
-    '',
-    [
-        'variant' => 'primary',
-    ],
-    [
-        'type' => 'submit',
-        'class' => 'self-end',
-    ],
-) ?>
-
-<?= form_close() ?>
+<Forms.Field
+    id="roles"
+    name="roles[]"
+    label="<?= lang('User.form.roles') ?>"
+    required="true"
+    options="<?= esc(json_encode($roleOptions)) ?>"
+    selected="<?= esc(json_encode($user->roles)) ?>" />
+
+<Button variant="primary" type="submit" class="self-end"><?= lang('User.form.submit_edit') ?></Button>
+
+</form>
 
 <?= $this->endSection() ?>
diff --git a/themes/cp_admin/user/list.php b/themes/cp_admin/user/list.php
index c5895513e4..e8d46852c4 100644
--- a/themes/cp_admin/user/list.php
+++ b/themes/cp_admin/user/list.php
@@ -9,10 +9,7 @@
 <?= $this->endSection() ?>
 
 <?= $this->section('headerRight') ?>
-<?= button(lang('User.create'), route_to('user-create'), [
-    'variant' => 'accent',
-    'iconLeft' => 'user-add',
-]) ?>
+<Button uri="<?= route_to('user-create') ?>" variant="accent" iconLeft="user-add"><?= lang('User.create') ?></Button>
 <?= $this->endSection() ?>
 
 
@@ -34,19 +31,9 @@
             'header' => lang('User.list.roles'),
             'cell' => function ($user) {
                 return implode(',', $user->roles) .
-                    icon_button(
-                        'edit',
-                        lang('User.edit_roles', [
-                            'username' => $user->username,
-                        ]),
-                        route_to('user-edit', $user->id),
-                        [
-                            'variant' => 'info',
-                        ],
-                        [
-                            'class' => 'ml-2',
-                        ],
-                    );
+                    '<IconButton uri="' . route_to('user-edit', $user->id) . '" glyph="edit" variant="info">' . lang('User.edit_roles', [
+                        'username' => $user->username,
+                    ]) . '</IconButton>';
             },
         ],
         [
@@ -60,39 +47,9 @@
         [
             'header' => lang('Common.actions'),
             'cell' => function ($user) {
-                return button(
-                    lang('User.forcePassReset'),
-                    route_to('user-force_pass_reset', $user->id),
-                    [
-                        'variant' => 'secondary',
-                        'size' => 'small',
-                    ],
-                    [
-                        'class' => 'mr-2',
-                    ],
-                ) .
-                    button(
-                        lang('User.' . ($user->isBanned() ? 'unban' : 'ban')),
-                        route_to(
-                            $user->isBanned() ? 'user-unban' : 'user-ban',
-                            $user->id,
-                        ),
-                        [
-                            'variant' => 'warning',
-                            'size' => 'small',
-                        ],
-                        [
-                            'class' => 'mr-2',
-                        ],
-                    ) .
-                    button(
-                        lang('User.delete'),
-                        route_to('user-delete', $user->id),
-                        [
-                            'variant' => 'danger',
-                            'size' => 'small',
-                        ],
-                    );
+                return '<Button uri="' . route_to('user-force_pass_reset', $user->id) . '" variant="secondary" size="small">' . lang('User.forcePassReset') . '</Button>' .
+                '<Button uri="' . route_to($user->isBanned() ? 'user-unban' : 'user-ban', $user->id) . '" variant="warning" size="small">' . lang('User.' . ($user->isBanned() ? 'unban' : 'ban')) . '</Button>' .
+                '<Button uri="' . route_to('user-delete', $user->id) . '" variant="danger" size="small">' . lang('User.delete') . '</Button>';
             },
         ],
     ],
diff --git a/themes/cp_app/embeddable_player.php b/themes/cp_app/embeddable_player.php
index 755b5960b7..be5efafae8 100644
--- a/themes/cp_app/embeddable_player.php
+++ b/themes/cp_app/embeddable_player.php
@@ -21,8 +21,8 @@
 ] ?>; color: <?= $themeData['text'] ?>;">
     <img src="<?= $episode->image
     ->thumbnail_url ?>" alt="<?= $episode->title ?>" class="flex-shrink w-36 h-36" />
-    <div class="flex flex-col flex-1 min-w-0 px-4 py-2 h-36">
-        <div class="flex items-center">
+    <div class="flex flex-col items-start flex-1 min-w-0 px-4 py-2 h-36">
+        <div class="flex items-center w-full">
             <a href="<?= route_to(
         'podcast-activity',
         $podcast->handle,
@@ -31,7 +31,7 @@
 ] ?>;" class="mr-2 text-xs tracking-wider uppercase truncate opacity-75 hover:opacity-100" target="_blank">
                 <?= $podcast->title ?>
             </a>
-            <a href="https://castopod.org/" class="ml-auto text-3xl text-pine-700 hover:opacity-75" title="<?= lang(
+            <a href="https://castopod.org/" class="ml-auto text-3xl text-pine-500 hover:opacity-75" title="<?= lang(
     'Common.powered_by',
     [
         'castopod' => 'Castopod',
diff --git a/themes/cp_app/podcast/_partials/comment_actions_authenticated.php b/themes/cp_app/podcast/_partials/comment_actions_authenticated.php
index 2f4951c3ce..d4812080ee 100644
--- a/themes/cp_app/podcast/_partials/comment_actions_authenticated.php
+++ b/themes/cp_app/podcast/_partials/comment_actions_authenticated.php
@@ -6,13 +6,7 @@
         'numberOfLikes' => $comment->likes_count,
     ],
 ) ?>"><?= icon('heart', 'text-xl mr-1 text-gray-400 group-hover:text-red-600') . $comment->likes_count ?></button>
-        <?= button(
-    lang('Comment.reply'),
-    route_to('comment', $podcast->handle, $comment->episode->slug, $comment->id),
-    [
-        'size' => 'small',
-    ],
-) ?>
+        <Button uri="<?= route_to('comment', $podcast->handle, $comment->episode->slug, $comment->id) ?>" size="small"><?= lang('Comment.reply') ?></Button>
     </form>
     <?php if ($comment->replies_count): ?>
         <?= anchor(
diff --git a/themes/cp_app/podcast/_partials/comment_actions_from_post_authenticated.php b/themes/cp_app/podcast/_partials/comment_actions_from_post_authenticated.php
index 584cf03596..a5783ef457 100644
--- a/themes/cp_app/podcast/_partials/comment_actions_from_post_authenticated.php
+++ b/themes/cp_app/podcast/_partials/comment_actions_from_post_authenticated.php
@@ -6,13 +6,7 @@
         'numberOfLikes' => $comment->likes_count,
     ],
 ) ?>"><?= icon('heart', 'text-xl mr-1 text-gray-400 group-hover:text-red-600') . $comment->likes_count ?></button>
-        <?= button(
-    lang('Comment.reply'),
-    route_to('post', $podcast->handle, $comment->id),
-    [
-        'size' => 'small',
-    ],
-) ?>
+        <Button uri="<?= route_to('post', $podcast->handle, $comment->id) ?>" size="small"><?= lang('Comment.reply') ?></Button>
     </form>
     <?php if ($comment->replies_count): ?>
         <?= anchor(
diff --git a/themes/cp_app/podcast/_partials/comment_reply_actions_authenticated.php b/themes/cp_app/podcast/_partials/comment_reply_actions_authenticated.php
index d186046f4c..b11b9c01fd 100644
--- a/themes/cp_app/podcast/_partials/comment_reply_actions_authenticated.php
+++ b/themes/cp_app/podcast/_partials/comment_reply_actions_authenticated.php
@@ -6,12 +6,6 @@
         'numberOfLikes' => $reply->likes_count,
     ],
 ) ?>"><?= icon('heart', 'text-xl mr-1 text-gray-400 group-hover:text-red-600') . $reply->likes_count ?></button>
-        <?= button(
-    lang('Comment.reply'),
-    route_to('comment', $podcast->handle, $episode->slug, $reply->id),
-    [
-        'size' => 'small',
-    ],
-) ?>
+        <Button uri="<?= route_to('comment', $podcast->handle, $episode->slug, $reply->id) ?>" size="small"><?= lang('Comment.reply') ?></Button>
     </form>
 </footer>
diff --git a/themes/cp_app/podcast/_partials/comment_with_replies_authenticated.php b/themes/cp_app/podcast/_partials/comment_with_replies_authenticated.php
index ac8b9e57a4..dfd69dcc93 100644
--- a/themes/cp_app/podcast/_partials/comment_with_replies_authenticated.php
+++ b/themes/cp_app/podcast/_partials/comment_with_replies_authenticated.php
@@ -1,46 +1,21 @@
 <?=  $this->include('podcast/_partials/comment_card_authenticated') ?>
 <div class="-mt-2 overflow-hidden border-b border-l border-r post-replies rounded-b-xl">
-<?= form_open(
-    route_to('comment-attempt-reply', $podcast->id, $episode->id, $comment->id),
-    [
-        'class' => 'bg-gray-50 flex px-6 pt-8 pb-4',
-    ],
-) ?>
+<form action="<?= route_to('comment-attempt-reply', $podcast->id, $episode->id, $comment->id) ?>" method="POST" class="flex px-6 pt-8 pb-4 bg-gray-50">
 <img src="<?= interact_as_actor()
     ->avatar_image_url ?>" alt="<?= interact_as_actor()
     ->display_name ?>" class="w-12 h-12 mr-4 rounded-full ring-gray-50 ring-2" />
 <div class="flex flex-col flex-1">
-<?= form_textarea(
-        [
-            'id' => 'message',
-            'name' => 'message',
-            'class' => 'form-textarea mb-4 w-full',
-            'required' => 'required',
-            'placeholder' => lang('Comment.form.reply_to_placeholder', [
-                'actorUsername' => $comment->actor->username,
-            ]),
-        ],
-        old('message', '', false),
-        [
-            'rows' => 1,
-        ],
-    ) ?>
-<?= button(
-        lang('Comment.form.submit_reply'),
-        '',
-        [
-            'variant' => 'primary',
-            'size' => 'small',
-        ],
-        [
-            'type' => 'submit',
-            'class' => 'self-end',
-            'name' => 'action',
-            'value' => 'reply',
-        ],
-    ) ?>
+    <Forms.Textarea
+        name="message"
+        required="true"
+        class="w-full mb-4"
+        placeholder="<?= lang('Comment.form.reply_to_placeholder', [
+            'actorUsername' => $comment->actor->username,
+        ]) ?>"
+        rows="1" />
+    <Button variant="primary" size="small" type="submit" name="action" value="reply"><?= lang('Comment.form.submit_reply') ?></Button>
 </div>
-<?= form_close() ?>
+</form>
 
 <?php foreach ($comment->replies as $reply): ?>
     <?= view('podcast/_partials/comment_reply_authenticated', [
diff --git a/themes/cp_app/podcast/_partials/post_with_replies_authenticated.php b/themes/cp_app/podcast/_partials/post_with_replies_authenticated.php
index 204976a9c7..9dbd498a43 100644
--- a/themes/cp_app/podcast/_partials/post_with_replies_authenticated.php
+++ b/themes/cp_app/podcast/_partials/post_with_replies_authenticated.php
@@ -1,46 +1,21 @@
 <?= $this->include('podcast/_partials/post_authenticated') ?>
 <div class="-mt-2 overflow-hidden border-b border-l border-r post-replies rounded-b-xl">
-<?= form_open(
-    route_to('post-attempt-action', interact_as_actor()->username, $post->id),
-    [
-        'class' => 'bg-gray-50 flex px-6 pt-8 pb-4',
-    ],
-) ?>
+<form action="<?= route_to('post-attempt-action', interact_as_actor()->username, $post->id) ?>" method="POST" class="flex px-6 pt-8 pb-4 bg-gray-50" >
 <img src="<?= interact_as_actor()
     ->avatar_image_url ?>" alt="<?= interact_as_actor()
     ->display_name ?>" class="w-12 h-12 mr-4 rounded-full ring-gray-50 ring-2" />
 <div class="flex flex-col flex-1">
-<?= form_textarea(
-        [
-            'id' => 'message',
-            'name' => 'message',
-            'class' => 'form-textarea mb-4 w-full',
-            'required' => 'required',
-            'placeholder' => lang('Post.form.reply_to_placeholder', [
-                'actorUsername' => $post->actor->username,
-            ]),
-        ],
-        old('message', '', false),
-        [
-            'rows' => 1,
-        ],
-    ) ?>
-<?= button(
-        lang('Post.form.submit_reply'),
-        '',
-        [
-            'variant' => 'primary',
-            'size' => 'small',
-        ],
-        [
-            'type' => 'submit',
-            'class' => 'self-end',
-            'name' => 'action',
-            'value' => 'reply',
-        ],
-    ) ?>
+    <Forms.Textarea
+        name="message"
+        class="w-full mb-4"
+        required="true"
+        placeholder="<?= lang('Post.form.reply_to_placeholder', [
+            'actorUsername' => $post->actor->username,
+        ]) ?>"
+        rows="1" />
+    <Button variant="primary" size="small" type="submit" name="action" value="reply" class="self-end"><?= lang('Post.form.submit_reply') ?></Button>
 </div>
-<?= form_close() ?>
+</form>
 
 <?php if ($post->has_replies): ?>
     <?php foreach ($post->replies as $reply): ?>
diff --git a/themes/cp_app/podcast/activity_authenticated.php b/themes/cp_app/podcast/activity_authenticated.php
index 06bec8558d..0f7366059d 100644
--- a/themes/cp_app/podcast/activity_authenticated.php
+++ b/themes/cp_app/podcast/activity_authenticated.php
@@ -43,9 +43,7 @@
 </nav>
 
 <section class="max-w-2xl px-6 py-8 mx-auto">
-<?= form_open(route_to('post-attempt-create', interact_as_actor()->username), [
-    'class' => 'flex p-4 bg-white shadow rounded-xl',
-]) ?>
+<form action="<?= route_to('post-attempt-create', interact_as_actor()->username) ?>" method="POST" class="flex p-4 bg-white shadow rounded-xl">
     <?= csrf_field() ?>
 
     <?= view('_message_block') ?>
@@ -54,59 +52,32 @@
         ->avatar_image_url ?>" alt="<?= interact_as_actor()
         ->display_name ?>" class="w-12 h-12 mr-4 rounded-full" />
     <div class="flex flex-col flex-1 min-w-0">
-        <?= form_textarea(
-            [
-                'id' => 'message',
-                'name' => 'message',
-                'class' => 'form-textarea',
-                'required' => 'required',
-                'placeholder' => lang('Post.form.message_placeholder'),
-            ],
-            old('message', '', false),
-            [
-                'rows' => 2,
-            ],
-        ) ?>
-        <?= form_input([
-            'id' => 'episode_url',
-            'name' => 'episode_url',
-            'class' => 'form-input mb-2',
-            'placeholder' =>
-                lang('Post.form.episode_url_placeholder') .
-                ' (' .
-                lang('Common.optional') .
-                ')',
-            'type' => 'url',
-        ]) ?>
-
-        <?= button(
-            lang('Post.form.submit'),
-            '',
-            [
-                'variant' => 'primary',
-                'size' => 'small',
-            ],
-            [
-                'type' => 'submit',
-                'class' => 'self-end',
-            ],
-        ) ?>
+        <Forms.Textarea
+            name="message"
+            required="true"
+            placeholder="<?= lang('Post.form.message_placeholder') ?>"
+            rows="2" />
+        <Forms.Input
+            name="episode_url"
+            type="url"
+            placeholder="<?= lang('Post.form.episode_url_placeholder') . ' (' . lang('Common.optional') . ')' ?>" />
+        <Button variant="primary" size="small" type="submit" class="self-end mt-2"><?= lang('Post.form.submit') ?></Button>
     </div>
-<?= form_close() ?>
+</form>
 <hr class="my-4 border-2 border-pine-100">
 
 <div class="space-y-8">
 <?php foreach ($posts as $post): ?>
     <?php if ($post->reblog_of_id !== null): ?>
         <?= view('podcast/_partials/reblog_authenticated', [
-            'post' => $post->reblog_of_post,
+    'post' => $post->reblog_of_post,
             'podcast' => $podcast,
-        ]) ?>
+]) ?>
     <?php else: ?>
         <?= view('podcast/_partials/post_authenticated', [
-            'post' => $post,
+    'post' => $post,
             'podcast' => $podcast,
-        ]) ?>
+]) ?>
     <?php endif; ?>
 <?php endforeach; ?>
 </div>
diff --git a/themes/cp_app/podcast/episode_authenticated.php b/themes/cp_app/podcast/episode_authenticated.php
index f1a2b79a0a..b84788284c 100644
--- a/themes/cp_app/podcast/episode_authenticated.php
+++ b/themes/cp_app/podcast/episode_authenticated.php
@@ -88,9 +88,7 @@
 
         <div class="tab-panels">
             <section id="comments" class="space-y-6 tab-panel">
-            <?= form_open(route_to('comment-attempt-create', $podcast->id, $episode->id), [
-                'class' => 'flex p-4',
-            ]) ?>
+            <form action="<?= route_to('comment-attempt-create', $podcast->id, $episode->id)  ?>" method="POST" class="flex p-4">
                 <?= csrf_field() ?>
 
                 <?= view('_message_block') ?>
@@ -99,47 +97,23 @@
                     ->avatar_image_url ?>" alt="<?= interact_as_actor()
                     ->display_name ?>" class="w-12 h-12 mr-4 rounded-full" />
                 <div class="flex flex-col flex-1 min-w-0">
-                    <?= form_textarea(
-                        [
-                            'id' => 'message',
-                            'name' => 'message',
-                            'class' => 'form-textarea mb-2',
-                            'required' => 'required',
-                            'placeholder' => lang(
-                                'Comment.form.episode_message_placeholder',
-                            ),
-                        ],
-                        old('message', '', false),
-                        [
-                            'rows' => 2,
-                        ],
-                    ) ?>
-
-                    <?= button(
-                        lang('Comment.form.submit'),
-                        '',
-                        [
-                            'variant' => 'primary',
-                            'size' => 'small',
-                        ],
-                        [
-                            'type' => 'submit',
-                            'class' => 'self-end',
-                        ],
-                    ) ?>
+                    <Forms.Textarea
+                        name="message"
+                        required="true"
+                        placeholder="<?= lang('Comment.form.episode_message_placeholder') ?>"
+                        rows="2" />
+                    <Button class="self-end" variant="primary" size="small" type="submit"><?= lang('Comment.form.submit') ?></Button>
                 </div>
-                <?= form_close() ?>
-                <?php foreach ($episode->comments as $comment): ?>
-                    <?= view('podcast/_partials/comment_authenticated', [
-                        'comment' => $comment,
-                        'podcast' => $podcast,
-                    ]) ?>
-                <?php endforeach; ?>
+            </form>
+            <?php foreach ($episode->comments as $comment): ?>
+                <?= view('podcast/_partials/comment_authenticated', [
+                    'comment' => $comment,
+                    'podcast' => $podcast,
+                ]) ?>
+            <?php endforeach; ?>
             </section>
             <section id="activity" class="space-y-8 tab-panel">
-                <?= form_open(route_to('post-attempt-create', $podcast->handle), [
-                    'class' => 'flex p-4 bg-white shadow rounded-xl',
-                ]) ?>
+                <form action="<?= route_to('post-attempt-create', $podcast->handle) ?>" method="POST" class="flex p-4 bg-white shadow rounded-xl">
                 <?= csrf_field() ?>
 
                 <?= view('_message_block') ?>
@@ -148,41 +122,15 @@
                     ->avatar_image_url ?>" alt="<?= interact_as_actor()
                     ->display_name ?>" class="w-12 h-12 mr-4 rounded-full" />
                 <div class="flex flex-col flex-1 min-w-0">
-                    <?= form_textarea(
-                        [
-                            'id' => 'message',
-                            'name' => 'message',
-                            'class' => 'form-textarea mb-2',
-                            'required' => 'required',
-                            'placeholder' => lang(
-                                'Post.form.episode_message_placeholder',
-                            ),
-                        ],
-                        old('message', '', false),
-                        [
-                            'rows' => 2,
-                        ],
-                    ) ?>
-                    <?= form_input([
-                        'id' => 'episode_url',
-                        'name' => 'episode_url',
-                        'value' => $episode->link,
-                        'type' => 'hidden',
-                    ]) ?>
-                    <?= button(
-                        lang('Post.form.submit'),
-                        '',
-                        [
-                            'variant' => 'primary',
-                            'size' => 'small',
-                        ],
-                        [
-                            'type' => 'submit',
-                            'class' => 'self-end',
-                        ],
-                    ) ?>
+                    <input name="episode_url" value="<?= $episode->link ?>" type="hidden" />
+                    <Forms.Textarea
+                        name="message"
+                        placeholder="<?= lang('Post.form.episode_message_placeholder') ?>"
+                        required="true"
+                        rows="2" />
+                    <Button variant="primary" size="small" type="submit" class="self-end"><?= lang('Post.form.submit') ?></Button>
                 </div>
-                <?= form_close() ?>
+                </form>
                 <hr class="my-4 border border-pine-100">
                 <?php foreach ($episode->posts as $post): ?>
                     <?= view('podcast/_partials/post_authenticated', [
diff --git a/themes/cp_app/podcast/follow.php b/themes/cp_app/podcast/follow.php
index 5743e47767..32682ae9b7 100644
--- a/themes/cp_app/podcast/follow.php
+++ b/themes/cp_app/podcast/follow.php
@@ -48,39 +48,18 @@
     </header>
 
     <main class="w-full max-w-md px-4 mx-auto">
-        <?= form_open(route_to('attempt-follow', $actor->username), [
-            'method' => 'post',
-            'class' => 'flex flex-col',
-        ]) ?>
+        <form action="<?= route_to('attempt-follow', $actor->username) ?>" method="POST" class="flex flex-col">
         <?= csrf_field() ?>
         <?= view('_message_block') ?>
 
-        <?= form_label(
-            lang('Fediverse.your_handle'),
-            'handle',
-            [],
-            lang('Fediverse.your_handle_hint'),
-        ) ?>
-        <?= form_input([
-            'id' => 'handle',
-            'name' => 'handle',
-            'class' => 'form-input mb-4',
-            'required' => 'required',
-            'type' => 'text',
-        ]) ?>
-
-        <?= button(
-            lang('Fediverse.follow.submit'),
-            '',
-            [
-                'variant' => 'primary',
-            ],
-            [
-                'type' => 'submit',
-                'class' => 'self-end',
-            ],
-        ) ?>
-        <?= form_close() ?>
+        <Forms.Field
+            name="handle"
+            label="<?= lang('Fediverse.your_handle') ?>"
+            hint="<?= lang('Fediverse.your_handle_hint') ?>"
+            required="true"
+        />
+        <Button variant="primary" type="submit" class="self-end"><?= lang('Fediverse.follow.submit') ?></Button>
+        </form>
     </main>
 
     <footer
diff --git a/themes/cp_app/podcast/post_remote_action.php b/themes/cp_app/podcast/post_remote_action.php
index b85b6db5d0..6e2205abe6 100644
--- a/themes/cp_app/podcast/post_remote_action.php
+++ b/themes/cp_app/podcast/post_remote_action.php
@@ -40,41 +40,17 @@
     <main class="flex-1 max-w-xl px-4 pb-8 mx-auto -mt-24">
         <?= $this->include('podcast/_partials/post') ?>
 
-        <?= form_open(
-            route_to('post-attempt-remote-action', $post->id, $action),
-            [
-                'method' => 'post',
-                'class' => 'flex flex-col mt-8',
-            ],
-        ) ?>
-        <?= csrf_field() ?>
-        <?= view('_message_block') ?>
+        <form action="<?= route_to('post-attempt-remote-action', $post->id, $action) ?>" method="POST" class="flex flex-col mt-8">
+            <?= csrf_field() ?>
+            <?= view('_message_block') ?>
 
-        <?= form_label(
-            lang('Fediverse.your_handle'),
-            'handle',
-            [],
-            lang('Fediverse.your_handle_hint'),
-        ) ?>
-        <?= form_input([
-            'id' => 'handle',
-            'name' => 'handle',
-            'class' => 'form-input mb-4',
-            'required' => 'required',
-            'type' => 'text',
-        ]) ?>
+            <Forms.Field
+                name="handle"
+                label="<?= lang('Fediverse.your_handle') ?>"
+                hint="<?= lang('Fediverse.your_handle_hint') ?>"
+                required="true" />
 
-        <?= button(
-            lang('Fediverse.' . $action . '.submit'),
-            '',
-            [
-                'variant' => 'primary',
-            ],
-            [
-                'type' => 'submit',
-                'class' => 'self-end',
-            ],
-        ) ?>
-        <?= form_close() ?>
+            <Button variant="primary" type="submit" class="self-end"><?= lang('Fediverse.' . $action . '.submit') ?></Button>
+        </form>
     </main>
 </body>
diff --git a/themes/cp_auth/forgot.php b/themes/cp_auth/forgot.php
index 5bc1aca18e..5491648d7d 100644
--- a/themes/cp_auth/forgot.php
+++ b/themes/cp_auth/forgot.php
@@ -10,32 +10,15 @@
 
 <p class="mb-4 text-gray-600"><?= lang('Auth.enterEmailForInstructions') ?></p>
 
-<?= form_open(route_to('forgot'), [
-    'class' => 'flex flex-col',
-]) ?>
-<?= csrf_field() ?>
-
-<?= form_label(lang('Auth.emailAddress'), 'email') ?>
-<?= form_input([
-    'id' => 'email',
-    'name' => 'email',
-    'class' => 'form-input mb-4',
-    'type' => 'email',
-    'required' => 'required',
-]) ?>
-
-<?= button(
-    lang('Auth.sendInstructions'),
-    '',
-    [
-        'variant' => 'primary',
-    ],
-    [
-        'type' => 'submit',
-        'class' => 'self-end',
-    ],
-) ?>
-
-<?= form_close() ?>
+<form action="<?= route_to('forgot') ?>" method="POST" class="flex flex-col">
+    <?= csrf_field() ?>
+
+    <Forms.Field
+        name="email"
+        label="<?= lang('Auth.emailAddress') ?>"
+        type="email"
+        required="true" />
+    <Button variant="primary" type="submit" class="self-end"><?= lang('Auth.sendInstructions') ?></Button>
+</form>
 
 <?= $this->endSection() ?>
diff --git a/themes/cp_auth/login.php b/themes/cp_auth/login.php
index 62c9f8d7e9..d70c88dba8 100644
--- a/themes/cp_auth/login.php
+++ b/themes/cp_auth/login.php
@@ -8,42 +8,22 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open(route_to('login'), [
-    'class' => 'flex flex-col',
-]) ?>
-<?= csrf_field() ?>
-
-<?= form_label(lang('Auth.emailOrUsername'), 'login') ?>
-<?= form_input([
-    'id' => 'login',
-    'name' => 'login',
-    'class' => 'form-input mb-4',
-    'required' => 'required',
-]) ?>
-
-<?= form_label(lang('Auth.password'), 'password') ?>
-<?= form_input([
-    'id' => 'password',
-    'name' => 'password',
-    'class' => 'form-input mb-4',
-    'type' => 'password',
-    'required' => 'required',
-]) ?>
-
-
-<?= button(
-    lang('Auth.loginAction'),
-    '',
-    [
-        'variant' => 'primary',
-    ],
-    [
-        'type' => 'submit',
-        'class' => 'self-end',
-    ],
-) ?>
-
-<?= form_close() ?>
+<form actions="<?= route_to('login') ?>" method="POST" class="flex flex-col">
+    <?= csrf_field() ?>
+
+    <Forms.Field
+        name="login"
+        label="<?= lang('Auth.emailOrUsername') ?>"
+        required="true" />
+
+    <Forms.Field
+        name="password"
+        label="<?= lang('Auth.password') ?>"
+        type="password"
+        required="true" />
+
+    <Button variant="primary" type="submit" class="self-end"><?= lang('Auth.loginAction') ?></Button>
+</form>
 
 <?= $this->endSection() ?>
 
diff --git a/themes/cp_auth/register.php b/themes/cp_auth/register.php
index 5bef3813f1..2be3657e6b 100644
--- a/themes/cp_auth/register.php
+++ b/themes/cp_auth/register.php
@@ -8,57 +8,30 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open(route_to('register'), [
-    'class' => 'flex flex-col',
-]) ?>
+<form action="<?= route_to('register') ?>" method="POST" class="flex flex-col">
 <?= csrf_field() ?>
 
-<?= form_label(lang('Auth.email'), 'email') ?>
-<?= form_input([
-    'id' => 'email',
-    'name' => 'email',
-    'class' => 'form-input',
-    'value' => old('email'),
-    'type' => 'email',
-    'required' => 'required',
-    'aria-describedby' => 'emailHelp',
-]) ?>
-<small id="emailHelp" class="mb-4 text-gray-700">
-    <?= lang('Auth.weNeverShare') ?>
-</small>
+<Forms.Field
+    name="email"
+    label="<?= lang('Auth.email') ?>"
+    helper="<?= lang('Auth.weNeverShare') ?>"
+    type="email"
+    required="true" />
 
-<?= form_label(lang('Auth.username'), 'username') ?>
-<?= form_input([
-    'id' => 'username',
-    'name' => 'username',
-    'class' => 'form-input mb-4',
-    'value' => old('username'),
-    'required' => 'required',
-]) ?>
+<Forms.Field
+    name="username"
+    label="<?= lang('Auth.username') ?>"
+    required="true" />
 
-<?= form_label(lang('Auth.password'), 'password') ?>
-<?= form_input([
-    'id' => 'password',
-    'name' => 'password',
-    'class' => 'form-input mb-4',
-    'type' => 'password',
-    'required' => 'required',
-    'autocomplete' => 'new-password',
-]) ?>
+<Forms.Field
+    name="password"
+    label="<?= lang('Auth.password') ?>"
+    required="true"
+    autocomplete="new-password" />
 
-<?= button(
-    lang('Auth.register'),
-    '',
-    [
-        'variant' => 'primary',
-    ],
-    [
-        'type' => 'submit',
-        'class' => 'self-end',
-    ],
-) ?>
+<Button variant="primary" type="submit" class="self-end"><?= lang('Auth.register') ?></Button>
 
-<?= form_close() ?>
+</form>
 
 <?= $this->endSection() ?>
 
diff --git a/themes/cp_auth/reset.php b/themes/cp_auth/reset.php
index 6493cbcebf..09c597e6ec 100644
--- a/themes/cp_auth/reset.php
+++ b/themes/cp_auth/reset.php
@@ -10,52 +10,30 @@
 
 <p class="mb-4"><?= lang('Auth.enterCodeEmailPassword') ?></p>
 
-<?= form_open(route_to('reset-password'), [
-    'class' => 'flex flex-col',
-]) ?>
+<form action="<?= route_to('reset-password') ?>" method="POST" class="flex flex-col">
 <?= csrf_field() ?>
 
-<?= form_label(lang('Auth.token'), 'token') ?>
-<?= form_input([
-    'id' => 'token',
-    'name' => 'token',
-    'class' => 'form-input mb-4',
-    'value' => old('token', $token ?? ''),
-    'required' => 'required',
-]) ?>
-
-<?= form_label(lang('Auth.email'), 'email') ?>
-<?= form_input([
-    'id' => 'email',
-    'name' => 'email',
-    'class' => 'form-input mb-4',
-    'value' => old('email'),
-    'required' => 'required',
-    'type' => 'email',
-]) ?>
-
-<?= form_label(lang('Auth.newPassword'), 'password') ?>
-<?= form_input([
-    'id' => 'password',
-    'name' => 'password',
-    'class' => 'form-input mb-4',
-    'type' => 'password',
-    'required' => 'required',
-    'autocomplete' => 'new-password',
-]) ?>
-
-<?= button(
-    lang('Auth.resetPassword'),
-    '',
-    [
-        'variant' => 'primary',
-    ],
-    [
-        'type' => 'submit',
-        'class' => 'self-end',
-    ],
-) ?>
-
-<?= form_close() ?>
+<Forms.Field
+    name="token"
+    label="<?= lang('Auth.token') ?>"
+    value="<?= $token ?? '' ?>"
+    required="true" />
+    
+<Forms.Field
+    name="email"
+    label="<?= lang('Auth.email') ?>"
+    type="email"
+    required="true" />
+
+<Forms.Field
+    name="password"
+    label="<?= lang('Auth.newPassword') ?>"
+    type="password"
+    required="true"
+    autocomplete="new-password" />
+
+<Button variant="primary" type="submit" class="self-end"><?= lang('Auth.resetPassword') ?></Button>
+
+</form>
 
 <?= $this->endSection() ?>
diff --git a/themes/cp_install/_layout.php b/themes/cp_install/_layout.php
index 4236492649..eccc912fbf 100644
--- a/themes/cp_install/_layout.php
+++ b/themes/cp_install/_layout.php
@@ -16,7 +16,7 @@
 <body class="flex flex-col min-h-screen mx-auto">
     <header class="border-b">
         <div class="container flex items-center justify-between px-2 py-4 mx-auto">
-            Castopod installer
+            <?= lang('Install.title') ?>
         </div>
     </header>
     <main class="container flex flex-col items-center justify-center flex-1 px-4 py-10 mx-auto">
diff --git a/themes/cp_install/cache_config.php b/themes/cp_install/cache_config.php
index c4563b79b2..21ea7740a9 100644
--- a/themes/cp_install/cache_config.php
+++ b/themes/cp_install/cache_config.php
@@ -2,48 +2,33 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open(route_to('cache-config'), [
-    'class' => 'flex flex-col max-w-sm w-full',
-]) ?>
+<form action="<?= route_to('cache-config') ?>" method="POST" class="flex flex-col w-full max-w-sm gap-y-4">
 <?= csrf_field() ?>
 
-<h1 class="mb-4 text-xl font-bold font-display"><span class="inline-flex items-center justify-center w-12 h-12 mr-2 text-sm font-semibold tracking-wider border-4 rounded-full text-pine-700 border-pine-700">3/4</span><?= lang(
-    'Install.form.cache_config',
-) ?></h1>
+<div class="flex flex-col mb-2">
+    <div class="flex items-center">
+        <span class="inline-flex items-center justify-center w-12 h-12 mr-2 text-sm font-semibold tracking-wider border-4 rounded-full text-pine-700 border-pine-700">3/4</span>
+        <Heading tagName="h1"><?= lang('Install.form.cache_config') ?></h1>
+    </div>
 
-<p class="mb-4 text-sm text-gray-600"><?= lang(
+    <p class="mt-2 text-sm text-gray-600"><?= lang(
     'Install.form.cache_config_hint',
 ) ?></p>
+</div>
 
-<?= form_label(lang('Install.form.cache_handler'), 'db_prefix') ?>
-<?= form_dropdown(
-    'cache_handler',
-    [
+<Forms.Field
+    as="Select"
+    name="cache_handler"
+    label="<?= lang('Install.form.cache_handler') ?>"
+    options="<?= esc(json_encode([
         'file' => lang('Install.form.cacheHandlerOptions.file'),
         'redis' => lang('Install.form.cacheHandlerOptions.redis'),
         'predis' => lang('Install.form.cacheHandlerOptions.predis'),
-    ],
-    [old('cache_handler', 'file')],
-    [
-        'id' => 'cache_handler',
-        'name' => 'cache_handler',
-        'class' => 'form-select mb-6',
-        'value' => config('Database')
-            ->default['DBPrefix'],
-    ],
-) ?>
-
-<?= button(
-    lang('Install.form.next') . icon('arrow-right', 'ml-2'),
-    '',
-    [
-        'variant' => 'primary',
-    ],
-    [
-        'type' => 'submit',
-        'class' => 'self-end',
-    ],
-) ?>
+    ])) ?>"
+    selected="file"
+    required="true" />
+
+<Button variant="primary" class="self-end" iconRight="arrow-right" type="submit"><?= lang('Install.form.next') ?></Button>
 
 <?= form_close() ?>
 
diff --git a/themes/cp_install/create_superadmin.php b/themes/cp_install/create_superadmin.php
index 6e2b829933..16771ce1e5 100644
--- a/themes/cp_install/create_superadmin.php
+++ b/themes/cp_install/create_superadmin.php
@@ -2,56 +2,34 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open(route_to('create-superadmin'), [
-    'class' => 'flex flex-col max-w-sm w-full',
-]) ?>
+<form action="<?= route_to('create-superadmin') ?>" method="POST" class="flex flex-col w-full max-w-sm gap-y-4">
 <?= csrf_field() ?>
 
-<h1 class="mb-4 text-xl font-bold font-display"><span class="inline-flex items-center justify-center w-12 h-12 mr-2 text-sm font-semibold tracking-wider border-4 rounded-full text-pine-700 border-pine-700">4/4</span><?= lang(
-    'Install.form.create_superadmin',
-) ?></h1>
-
-<?= form_label(lang('Install.form.email'), 'email') ?>
-<?= form_input([
-    'id' => 'email',
-    'name' => 'email',
-    'class' => 'form-input mb-4',
-    'type' => 'email',
-    'required' => 'required',
-    'value' => old('email'),
-]) ?>
-
-<?= form_label(lang('Install.form.username'), 'username') ?>
-<?= form_input([
-    'id' => 'username',
-    'name' => 'username',
-    'class' => 'form-input mb-4',
-    'required' => 'required',
-    'value' => old('username'),
-]) ?>
-
-<?= form_label(lang('Install.form.password'), 'password') ?>
-<?= form_input([
-    'id' => 'password',
-    'name' => 'password',
-    'class' => 'form-input mb-4',
-    'type' => 'password',
-    'required' => 'required',
-    'autocomplete' => 'new-password',
-]) ?>
-
-<?= button(
-    icon('check', 'mr-2') . lang('Install.form.submit'),
-    '',
-    [
-        'variant' => 'primary',
-    ],
-    [
-        'type' => 'submit',
-        'class' => 'self-end',
-    ],
-) ?>
-
-<?= form_close() ?>
+<div class="flex items-center mb-2">
+    <span class="inline-flex items-center justify-center w-12 h-12 mr-2 text-sm font-semibold tracking-wider border-4 rounded-full text-pine-700 border-pine-700">4/4</span>
+    <Heading tagName="h1"><?= lang('Install.form.create_superadmin') ?></Heading>
+</div>
+
+<Forms.Field
+    name="email"
+    label="<?= lang('Install.form.email') ?>"
+    type="email"
+    required="true" />
+
+<Forms.Field
+    name="username"
+    label="<?= lang('Install.form.username') ?>"
+    required="true" />
+
+<Forms.Field
+    name="password"
+    label="<?= lang('Install.form.password') ?>"
+    type="password"
+    required="true"
+    autocomplete="new-password" />
+
+<Button variant="primary" type="submit" class="self-end" iconLeft="check"><?= lang('Install.form.submit') ?></Button>
+
+</form>
 
 <?= $this->endSection() ?>
diff --git a/themes/cp_install/database_config.php b/themes/cp_install/database_config.php
index 425e31002c..61b3f24978 100644
--- a/themes/cp_install/database_config.php
+++ b/themes/cp_install/database_config.php
@@ -2,84 +2,58 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open(route_to('database-config'), [
-    'class' => 'flex flex-col max-w-sm w-full',
-    'autocomplete' => 'off',
-]) ?>
+<form action="<?= route_to('database-config') ?>" method="POST" class="flex flex-col w-full max-w-sm gap-y-4" autocomplete="off">
 <?= csrf_field() ?>
 
-<h1 class="mb-2 text-xl font-bold font-display"><span class="inline-flex items-center justify-center w-12 h-12 mr-2 text-sm font-semibold tracking-wider border-4 rounded-full text-pine-700 border-pine-700">2/4</span><?= lang(
+<div class="flex flex-col mb-2">
+    <div class="flex items-center">
+        <span class="inline-flex items-center justify-center w-12 h-12 mr-2 text-sm font-semibold tracking-wider border-4 rounded-full text-pine-700 border-pine-700">2/4</span>
+        <Heading tagName="h1"><?= lang(
     'Install.form.database_config',
-) ?></h1>
+) ?></Heading>
+    </div>
 
-<p class="mb-4 text-sm text-gray-600"><?= lang(
+    <p class="mt-2 text-sm text-gray-600"><?= lang(
     'Install.form.database_config_hint',
 ) ?></p>
-
-<?= form_label(lang('Install.form.db_hostname'), 'db_hostname') ?>
-<?= form_input([
-    'id' => 'db_hostname',
-    'name' => 'db_hostname',
-    'class' => 'form-input mb-4',
-    'value' => old('db_hostname', config('Database')->default['hostname']),
-    'required' => 'required',
-]) ?>
-
-<?= form_label(lang('Install.form.db_name'), 'db_name') ?>
-<?= form_input([
-    'id' => 'db_name',
-    'name' => 'db_name',
-    'class' => 'form-input mb-4',
-    'value' => old('db_name', config('Database')->default['database']),
-    'required' => 'required',
-]) ?>
-
-<?= form_label(lang('Install.form.db_username'), 'db_username') ?>
-<?= form_input([
-    'id' => 'db_username',
-    'name' => 'db_username',
-    'class' => 'form-input mb-4',
-    'value' => old('db_username', config('Database')->default['username']),
-    'required' => 'required',
-    'autocomplete' => 'off',
-]) ?>
-
-<?= form_label(lang('Install.form.db_password'), 'db_password') ?>
-<?= form_input([
-    'id' => 'db_password',
-    'name' => 'db_password',
-    'class' => 'form-input mb-4',
-    'value' => old('db_password', config('Database')->default['password']),
-    'type' => 'password',
-    'required' => 'required',
-    'autocomplete' => 'off',
-]) ?>
-
-<?= form_label(
-    lang('Install.form.db_prefix'),
-    'db_prefix',
-    [],
-    lang('Install.form.db_prefix_hint'),
-) ?>
-<?= form_input([
-    'id' => 'db_prefix',
-    'name' => 'db_prefix',
-    'class' => 'form-input mb-6',
-    'value' => old('db_prefix', config('Database')->default['DBPrefix']),
-]) ?>
-
-<?= button(
-    lang('Install.form.next') . icon('arrow-right', 'ml-2'),
-    '',
-    [
-        'variant' => 'primary',
-    ],
-    [
-        'type' => 'submit',
-        'class' => 'self-end',
-    ],
-) ?>
-
-<?= form_close() ?>
+</div>
+
+<Forms.Field
+    name="db_hostname"
+    label="<?= lang('Install.form.db_hostname') ?>"
+    value="<?= config('Database')
+    ->default['hostname'] ?>"
+    required="true" />
+
+<Forms.Field
+    name="db_name"
+    label="<?= lang('Install.form.db_name') ?>"
+    value="<?= config('Database')->default['database'] ?>"
+    required="true" />
+
+<Forms.Field
+    name="db_username"
+    label="<?= lang('Install.form.db_username') ?>"
+    value="<?= config('Database')->default['username'] ?>"
+    required="true"
+    autocomplete="off" />
+
+<Forms.Field
+    name="db_password"
+    label="<?= lang('Install.form.db_password') ?>"
+    value="<?= config('Database')->default['password'] ?>"
+    type="password"
+    required="true"
+    autocomplete="off" />
+
+<Forms.Field
+    name="db_prefix"
+    label="<?= lang('Install.form.db_prefix') ?>"
+    hint="<?= lang('Install.form.db_prefix_hint') ?>"
+    value="<?= config('Database')->default['DBPrefix'] ?>" />
+
+<Button variant="primary" type="submit" class="self-end" iconRight="arrow-right"><?= lang('Install.form.next') ?></Button>
+
+</form>
 
 <?= $this->endSection() ?>
diff --git a/themes/cp_install/instance_config.php b/themes/cp_install/instance_config.php
index 13af10f918..7e8620ac57 100644
--- a/themes/cp_install/instance_config.php
+++ b/themes/cp_install/instance_config.php
@@ -1,84 +1,44 @@
 <?= $this->extend('_layout') ?>
 
 <?= $this->section('content') ?>
-adz
-<form action="<?= '/' .
-    config('Install')
-        ->gateway .
-    '/instance-config' ?>" class="flex flex-col w-full max-w-sm" method="post" accept-charset="utf-8">
-<?= csrf_field() ?>
-
-<h1 class="mb-4 text-xl font-bold font-display"><span class="inline-flex items-center justify-center w-12 h-12 mr-2 text-sm font-semibold tracking-wider border-4 rounded-full text-pine-700 border-pine-700">1/4</span><?= lang(
-        'Install.form.instance_config',
-    ) ?></h1>
-<?= form_label(lang('Install.form.hostname'), 'hostname') ?>
-<?= form_input([
-    'id' => 'hostname',
-    'name' => 'hostname',
-    'class' => 'form-input mb-4',
-    'value' => old(
-        'hostname',
-        host_url() === null ? config('App')
-            ->baseURL : host_url(),
-    ),
-    'required' => 'required',
-]) ?>
-
-
-<?= form_label(
-    lang('Install.form.media_base_url'),
-    'media_base_url',
-    [],
-    lang('Install.form.media_base_url_hint'),
-    true,
-) ?>
-<?= form_input([
-    'id' => 'media_base_url',
-    'name' => 'media_base_url',
-    'class' => 'form-input mb-4',
-    'value' => old('media_base_url', ''),
-]) ?>
 
-<?= form_label(
-    lang('Install.form.admin_gateway'),
-    'admin_gateway',
-    [],
-    lang('Install.form.admin_gateway_hint'),
-) ?>
-<?= form_input([
-    'id' => 'admin_gateway',
-    'name' => 'admin_gateway',
-    'class' => 'form-input mb-4',
-    'value' => old('admin_gateway', config('Admin')->gateway),
-    'required' => 'required',
-]) ?>
-
-<?= form_label(
-    lang('Install.form.auth_gateway'),
-    'auth_gateway',
-    [],
-    lang('Install.form.auth_gateway_hint'),
-) ?>
-<?= form_input([
-    'id' => 'auth_gateway',
-    'name' => 'auth_gateway',
-    'class' => 'form-input mb-6',
-    'value' => old('auth_gateway', config('Auth')->gateway),
-    'required' => 'required',
-]) ?>
-
-<?= button(
-    lang('Install.form.next') . icon('arrow-right', 'ml-2'),
-    '',
-    [
-        'variant' => 'primary',
-    ],
-    [
-        'type' => 'submit',
-        'class' => 'self-end',
-    ],
-) ?>
+<form action="<?= '/' . config('Install')->gateway . '/instance-config' ?>" class="flex flex-col w-full max-w-sm gap-y-4" method="post" accept-charset="utf-8">
+<?= csrf_field() ?>
 
-<?= form_close() ?>
+<div class="flex items-center mb-4">
+    <span class="inline-flex items-center justify-center w-12 h-12 mr-2 text-sm font-semibold tracking-wider border-4 rounded-full text-pine-700 border-pine-700">1/4</span>
+    <Heading tagName="h1"><?= lang('Install.form.instance_config') ?></Heading>
+</div>
+
+<Forms.Field
+    name="hostname"
+    label="<?= lang('Install.form.hostname') ?>"
+    value="<?= host_url() === null ? config('App')
+    ->baseURL : host_url() ?>"
+    required="true" />
+
+<Forms.Field
+    name="media_base_url"
+    label="<?= lang('Install.form.media_base_url') ?>"
+    hint="<?= lang('Install.form.media_base_url_hint') ?>" />
+
+<Forms.Field
+    name="admin_gateway"
+    label="<?= lang('Install.form.admin_gateway') ?>"
+    hint="<?= lang('Install.form.admin_gateway_hint') ?>"
+    value="<?= config('Admin')
+    ->gateway ?>"
+    required="true" />
+
+<Forms.Field
+    name="auth_gateway"
+    label="<?= lang('Install.form.auth_gateway') ?>"
+    hint="<?= lang('Install.form.auth_gateway_hint') ?>"
+    value="<?= config('Auth')
+    ->gateway ?>"
+    required="true" />
+
+<Button class="self-end" variant="primary" type="submit" iconRight="arrow-right"><?= lang('Install.form.next') ?></Button>
+</form>
 
 <?= $this->endSection() ?>
-- 
GitLab