feat(plugins): add aside with plugin metadata next to plugin's readme

- enhance plugin card ui
- refactor components to be more consistent
- invert toggler label for better UX
- edit view components regex
This commit is contained in:
Yassine Doghri 2024-05-09 17:55:41 +00:00
parent e6bfdfc390
commit dfb7888aeb
193 changed files with 1632 additions and 1348 deletions

View File

@ -31,6 +31,6 @@ if (! function_exists('replace_breadcrumb_params')) {
function replace_breadcrumb_params(array $newParams): void function replace_breadcrumb_params(array $newParams): void
{ {
$breadcrumb = Services::breadcrumb(); $breadcrumb = Services::breadcrumb();
$breadcrumb->replaceParams(esc($newParams)); $breadcrumb->replaceParams($newParams);
} }
} }

View File

@ -16,31 +16,6 @@ use CodeIgniter\View\Table;
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
if (! function_exists('hint_tooltip')) {
/**
* Hint component
*
* Used to produce tooltip with a question mark icon for hint texts
*
* @param string $hintText The hint text
*/
function hint_tooltip(string $hintText = '', string $class = ''): string
{
$tooltip =
'<span data-tooltip="bottom" tabindex="0" title="' .
esc($hintText) .
'" class="inline-block align-middle opacity-75 focus:ring-accent';
if ($class !== '') {
$tooltip .= ' ' . $class;
}
return $tooltip . '">' . icon('question-fill') . '</span>';
}
}
// ------------------------------------------------------------------------
if (! function_exists('data_table')) { if (! function_exists('data_table')) {
/** /**
* Data table component * Data table component
@ -113,12 +88,12 @@ if (! function_exists('publication_pill')) {
*/ */
function publication_pill(?Time $publicationDate, string $publicationStatus, string $customClass = ''): string function publication_pill(?Time $publicationDate, string $publicationStatus, string $customClass = ''): string
{ {
$class = match ($publicationStatus) { $variant = match ($publicationStatus) {
'published' => 'text-pine-500 border-pine-500 bg-pine-50', 'published' => 'success',
'scheduled' => 'text-red-600 border-red-600 bg-red-50', 'scheduled' => 'warning',
'with_podcast' => 'text-blue-600 border-blue-600 bg-blue-50', 'with_podcast' => 'info',
'not_published' => 'text-gray-600 border-gray-600 bg-gray-50', 'not_published' => 'default',
default => 'text-gray-600 border-gray-600 bg-gray-50', default => 'default',
}; };
$title = match ($publicationStatus) { $title = match ($publicationStatus) {
@ -130,16 +105,12 @@ if (! function_exists('publication_pill')) {
$label = lang('Episode.publication_status.' . $publicationStatus); $label = lang('Episode.publication_status.' . $publicationStatus);
return '<span ' . ($title === '' ? '' : 'title="' . $title . '"') . ' class="flex items-center px-1 font-semibold border rounded w-max ' . // @icon('error-warning-fill')
$class . return '<x-Pill ' . ($title === '' ? '' : 'title="' . $title . '"') . ' variant="' . $variant . '" class="' . $customClass .
' ' . '">' . $label . ($publicationStatus === 'with_podcast' ? icon('error-warning-fill', [
$customClass .
'">' .
$label .
($publicationStatus === 'with_podcast' ? icon('error-warning-fill', [
'class' => 'flex-shrink-0 ml-1 text-lg', 'class' => 'flex-shrink-0 ml-1 text-lg',
]) : '') . ]) : '') .
'</span>'; '</x-Pill>';
} }
} }
@ -182,7 +153,7 @@ if (! function_exists('publication_button')) {
} }
return <<<HTML return <<<HTML
<Button variant="{$variant}" uri="{$route}" iconLeft="{$iconLeft}" >{$label}</Button> <x-Button variant="{$variant}" uri="{$route}" iconLeft="{$iconLeft}" >{$label}</x-Button>
HTML; HTML;
} }
} }
@ -356,7 +327,7 @@ if (! function_exists('location_link')) {
'class' => 'mr-2 flex-shrink-0', 'class' => 'mr-2 flex-shrink-0',
]) . '<span class="truncate">' . esc($location->name) . '</span>', ]) . '<span class="truncate">' . esc($location->name) . '</span>',
[ [
'class' => 'w-full overflow-hidden inline-flex items-baseline hover:underline focus:ring-accent' . 'class' => 'w-full overflow-hidden inline-flex items-baseline hover:underline' .
($class === '' ? '' : " {$class}"), ($class === '' ? '' : " {$class}"),
'target' => '_blank', 'target' => '_blank',
'rel' => 'noreferrer noopener', 'rel' => 'noreferrer noopener',

View File

@ -20,30 +20,30 @@ if (! function_exists('render_page_links')) {
{ {
$pages = (new PageModel())->findAll(); $pages = (new PageModel())->findAll();
$links = anchor(route_to('home'), lang('Common.home'), [ $links = anchor(route_to('home'), lang('Common.home'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent', 'class' => 'px-2 py-1 underline hover:no-underline',
]); ]);
if ($podcastHandle !== null) { if ($podcastHandle !== null) {
$links .= anchor(route_to('podcast-links', $podcastHandle), lang('Podcast.links'), [ $links .= anchor(route_to('podcast-links', $podcastHandle), lang('Podcast.links'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent', 'class' => 'px-2 py-1 underline hover:no-underline',
]); ]);
} }
$links .= anchor(route_to('credits'), lang('Person.credits'), [ $links .= anchor(route_to('credits'), lang('Person.credits'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent', 'class' => 'px-2 py-1 underline hover:no-underline',
]); ]);
$links .= anchor(route_to('map'), lang('Page.map.title'), [ $links .= anchor(route_to('map'), lang('Page.map.title'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent', 'class' => 'px-2 py-1 underline hover:no-underline',
]); ]);
foreach ($pages as $page) { foreach ($pages as $page) {
$links .= anchor($page->link, esc($page->title), [ $links .= anchor($page->link, esc($page->title), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent', 'class' => 'px-2 py-1 underline hover:no-underline',
]); ]);
} }
// if set in .env, add legal notice link at the end of page links // if set in .env, add legal notice link at the end of page links
if (config('App')->legalNoticeURL !== null) { if (config('App')->legalNoticeURL !== null) {
$links .= anchor(config('App')->legalNoticeURL, lang('Common.legal_notice'), [ $links .= anchor(config('App')->legalNoticeURL, lang('Common.legal_notice'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent', 'class' => 'px-2 py-1 underline hover:no-underline',
'target' => '_blank', 'target' => '_blank',
'rel' => 'noopener noreferrer', 'rel' => 'noopener noreferrer',
]); ]);

View File

@ -32,12 +32,18 @@ class Breadcrumb
$uri = ''; $uri = '';
foreach (current_url(true)->getSegments() as $segment) { foreach (current_url(true)->getSegments() as $segment) {
$uri .= '/' . $segment; $uri .= '/' . $segment;
$this->links[] = [ $link = [
'text' => is_numeric($segment) 'text' => is_numeric($segment)
? $segment ? $segment
: lang('Breadcrumb.' . $segment), : lang('Breadcrumb.' . $segment),
'href' => base_url($uri), 'href' => base_url($uri),
]; ];
if (is_numeric($segment)) {
$this->links[] = $link;
} else {
$this->links[$segment] = $link;
}
} }
} }
@ -46,20 +52,19 @@ class Breadcrumb
* *
* Given a breadcrumb with numeric params, this function replaces them with the values provided in $newParams * Given a breadcrumb with numeric params, this function replaces them with the values provided in $newParams
* *
* Example with `Home / podcasts / 1 / episodes / 1` * Example with `Home / podcasts / 1 / episodes / 1 / foo`
* *
* $newParams = [ 0 => 'foo', 1 => 'bar' ] replaceParams($newParams); * $newParams = [ 0 => 'bar', 1 => 'baz', 'foo' => 'I Pity The Foo' ] replaceParams($newParams);
* *
* The breadcrumb is now `Home / podcasts / foo / episodes / bar` * The breadcrumb is now `Home / podcasts / foo / episodes / bar / I Pity The Foo`
* *
* @param string[] $newParams * @param string[] $newParams
*/ */
public function replaceParams(array $newParams): void public function replaceParams(array $newParams): void
{ {
foreach ($this->links as $key => $link) { foreach ($newParams as $key => $newValue) {
if (is_numeric($link['text'])) { if (array_key_exists($key, $this->links)) {
$this->links[$key]['text'] = $newParams[0]; $this->links[$key]['text'] = $newValue;
array_shift($newParams);
} }
} }
} }

View File

@ -4,26 +4,30 @@ declare(strict_types=1);
namespace ViewComponents; namespace ViewComponents;
class Component implements ComponentInterface abstract class Component implements ComponentInterface
{ {
protected string $slot = ''; /**
* @var list<string>
*/
protected array $props = [];
protected string $class = ''; /**
* @var array<string, string|'boolean'|'array'|'number'>
*/
protected array $casts = [];
protected ?string $slot = null;
/** /**
* @var array<string, string> * @var array<string, string>
*/ */
protected array $attributes = [ protected array $attributes = [];
'class' => '',
];
/** /**
* @param array<string, string> $attributes * @param array<string, string> $attributes
*/ */
public function __construct(array $attributes) public function __construct(array $attributes)
{ {
helper('viewcomponents');
// overwrite default attributes if set // overwrite default attributes if set
$this->attributes = [...$this->attributes, ...$attributes]; $this->attributes = [...$this->attributes, ...$attributes];
@ -42,9 +46,39 @@ class Component implements ComponentInterface
if (is_callable([$this, $method])) { if (is_callable([$this, $method])) {
$this->{$method}($value); $this->{$method}($value);
} else { } else {
if (array_key_exists($name, $this->casts)) {
$value = match ($this->casts[$name]) {
'boolean' => $value === 'true',
'number' => (int) $value,
'array' => json_decode(htmlspecialchars_decode($value), true),
default => $value
};
}
$this->{$name} = $value; $this->{$name} = $value;
} }
// remove from attributes
if (in_array($name, $this->props, true)) {
unset($this->attributes[$name]);
}
} }
unset($this->attributes['slot']);
}
public function mergeClass(string $class): void
{
if (! array_key_exists('class', $this->attributes)) {
$this->attributes['class'] = $class;
} else {
$this->attributes['class'] .= ' ' . $class;
}
}
public function getStringifiedAttributes(): string
{
return stringify_attributes($this->attributes);
} }
public function render(): string public function render(): string

View File

@ -43,38 +43,38 @@ class ComponentRenderer
private function renderSelfClosingTags(string $output): string private function renderSelfClosingTags(string $output): string
{ {
// Pattern borrowed and adapted from Laravel's ComponentTagCompiler // Pattern borrowed and adapted from Laravel's ComponentTagCompiler
// Should match any Component tags <Component /> // Should match any Component tags <x-Component />
$pattern = "/ $pattern = "/
< <
\s* \\s*
(?<name>[A-Z][A-Za-z0-9\.]*?) x[-\\:](?<name>[\\w\\-\\:\\.]*)
\s* \\s*
(?<attributes> (?<attributes>
(?: (?:
\s+ \\s+
(?: (?:
(?: (?:
\{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} \\{\\{\\s*\\\$attributes(?:[^}]+?)?\\s*\\}\\}
) )
| |
(?: (?:
[\w\-:.@]+ [\\w\\-:.@]+
( (
= =
(?: (?:
\\\"[^\\\"]*\\\" \\\"[^\\\"]*\\\"
| |
\'[^\']*\' \\'[^\\']*\\'
| |
[^\'\\\"=<>]+ [^\\'\\\"=<>]+
) )
)? )?
) )
) )
)* )*
\s* \\s*
) )
\/> \\/>
/x"; /x";
/* /*
@ -96,8 +96,9 @@ class ComponentRenderer
private function renderPairedTags(string $output): string private function renderPairedTags(string $output): string
{ {
$pattern = '/<\s*(?<name>[A-Z][A-Za-z0-9\.]*?)(?<attributes>(\s*[\w\-]+\s*=\s*(\'[^\']*\'|\"[^\"]*\"))+\s*)>(?<slot>.*)<\/\s*\1\s*>/uUsm'; // ini_set('pcre.backtrack_limit', '-1');
ini_set('pcre.backtrack_limit', '-1'); $pattern = '/<\s*x[-\:](?<name>[\w\-\:\.]*?)(?<attributes>(\s*[\w\-]+\s*=\s*(\'[^\']*\'|\"[^\"]*\"))+\s*)>(?<slot>.*)<\/\s*x-\1\s*>/uiUsm';
/* /*
$matches[0] = full tags matched and all of its content $matches[0] = full tags matched and all of its content
$matches[name] = pascal cased tag name $matches[name] = pascal cased tag name
@ -167,8 +168,6 @@ class ComponentRenderer
( (
\"[^\"]+\" \"[^\"]+\"
| |
\'[^\']+\'
|
\\\'[^\\\']+\\\' \\\'[^\\\']+\\\'
| |
[^\s>]+ [^\s>]+

View File

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
if (! function_exists('flatten_attributes')) {
/**
* Stringify attributes for use in HTML tags.
*
* Helper function used to convert a string, array, or object of attributes to a string.
*
* @param mixed $attributes string, array, object
*/
function flatten_attributes(mixed $attributes, bool $js = false): string
{
$atts = '';
if ($attributes === null) {
return $atts;
}
if (is_string($attributes)) {
return ' ' . $attributes;
}
$attributes = (array) $attributes;
foreach ($attributes as $key => $val) {
$atts .= ($js) ? $key . '=' . esc($val, 'js') . ',' : ' ' . $key . '="' . $val . '"';
}
return rtrim($atts, ',');
}
}

View File

@ -15,10 +15,6 @@
&:hover { &:hover {
@apply underline; @apply underline;
} }
&:focus {
@apply ring-accent;
}
} }
.breadcrumb-item.active { .breadcrumb-item.active {

View File

@ -9,10 +9,6 @@
font-size: 16px; font-size: 16px;
} }
.choices:focus {
outline: none;
}
.choices:last-child { .choices:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
@ -327,10 +323,6 @@
cursor: pointer; cursor: pointer;
} }
.choices__button:focus {
outline: none;
}
.choices__input { .choices__input {
@apply mb-1 align-middle bg-elevated; @apply mb-1 align-middle bg-elevated;

View File

@ -5,6 +5,15 @@
} }
} }
.ring-accent {
@apply outline-none ring-2 ring-offset-2;
/* FIXME: why doesn't ring-accent-base work? */
--tw-ring-opacity: 1;
--tw-ring-color: hsl(var(--color-accent-base) / var(--tw-ring-opacity));
--tw-ring-offset-color: hsl(var(--color-background-base));
}
.rounded-conditional-b-xl { .rounded-conditional-b-xl {
border-bottom-right-radius: max( border-bottom-right-radius: max(
0px, 0px,

View File

@ -34,7 +34,7 @@ Read more component (basic unstyled component)
/* Don't forget focus and hover styles for accessibility! */ /* Don't forget focus and hover styles for accessibility! */
.read-more__checkbox:focus ~ .read-more__label { .read-more__checkbox:focus ~ .read-more__label {
@apply ring; @apply ring-accent;
} }
.read-more__checkbox:hover ~ .read-more__label { .read-more__checkbox:hover ~ .read-more__label {

View File

@ -8,9 +8,15 @@ use ViewComponents\Component;
class Alert extends Component class Alert extends Component
{ {
protected ?string $glyph = null; protected array $props = ['glyph', 'title'];
protected ?string $title = null; protected string $glyph = '';
protected ?string $title = '';
protected array $attributes = [
'role' => 'alert',
];
/** /**
* @var 'default'|'success'|'danger'|'warning' * @var 'default'|'success'|'danger'|'warning'
@ -19,7 +25,7 @@ class Alert extends Component
public function render(): string public function render(): string
{ {
$variants = [ $variantData = match ($this->variant) {
'success' => [ 'success' => [
'class' => 'text-pine-900 bg-pine-100 border-pine-300', 'class' => 'text-pine-900 bg-pine-100 border-pine-300',
'glyph' => 'check-fill', // @icon('check-fill') 'glyph' => 'check-fill', // @icon('check-fill')
@ -32,30 +38,21 @@ class Alert extends Component
'class' => 'text-yellow-900 bg-yellow-100 border-yellow-300', 'class' => 'text-yellow-900 bg-yellow-100 border-yellow-300',
'glyph' => 'alert-fill', // @icon('alert-fill') 'glyph' => 'alert-fill', // @icon('alert-fill')
], ],
'default' => [ default => [
'class' => 'text-blue-900 bg-blue-100 border-blue-300', 'class' => 'text-blue-900 bg-blue-100 border-blue-300',
'glyph' => 'error-warning-fill', // @icon('error-warning-fill') 'glyph' => 'error-warning-fill', // @icon('error-warning-fill')
], ],
]; };
if (! array_key_exists($this->variant, $variants)) { $glyph = icon(($this->glyph === '' ? $variantData['glyph'] : $this->glyph), [
$this->variant = 'default';
}
$glyph = icon(($this->glyph ?? $variants[$this->variant]['glyph']), [
'class' => 'flex-shrink-0 mr-2 text-lg', 'class' => 'flex-shrink-0 mr-2 text-lg',
]); ]);
$title = $this->title === null ? '' : '<div class="font-semibold">' . $this->title . '</div>'; $title = $this->title === '' ? '' : '<div class="font-semibold">' . $this->title . '</div>';
$class = 'inline-flex w-full p-2 text-sm border rounded ' . $variants[$this->variant]['class'] . ' ' . $this->class; $this->mergeClass('inline-flex w-full p-2 text-sm border rounded ');
$this->mergeClass($variantData['class']);
unset($this->attributes['slot']);
unset($this->attributes['variant']);
unset($this->attributes['class']);
unset($this->attributes['glyph']);
$attributes = stringify_attributes($this->attributes);
return <<<HTML return <<<HTML
<div class="{$class}" role="alert" {$attributes}>{$glyph}<div>{$title}<p>{$this->slot}</p></div></div> <div {$this->getStringifiedAttributes()}>{$glyph}<div>{$title}<p>{$this->slot}</p></div></div>
HTML; HTML;
} }
} }

View File

@ -8,10 +8,20 @@ use ViewComponents\Component;
class Button extends Component class Button extends Component
{ {
protected array $props = ['uri', 'variant', 'size', 'iconLeft', 'iconRight', 'isSquared', 'isExternal'];
protected array $casts = [
'isSquared' => 'boolean',
'isExternal' => 'boolean',
];
protected string $uri = ''; protected string $uri = '';
protected string $variant = 'default'; protected string $variant = 'default';
/**
* @var 'small'|'base'|'large'
*/
protected string $size = 'base'; protected string $size = 'base';
protected string $iconLeft = ''; protected string $iconLeft = '';
@ -20,65 +30,54 @@ class Button extends Component
protected bool $isSquared = false; protected bool $isSquared = false;
public function setIsSquared(string $value): void protected bool $isExternal = false;
{
$this->isSquared = $value === 'true';
}
public function render(): string public function render(): string
{ {
$baseClass = $this->mergeClass('gap-x-2 flex-shrink-0 inline-flex items-center justify-center font-semibold rounded-full');
'gap-x-2 flex-shrink-0 inline-flex items-center justify-center font-semibold rounded-full';
$variantClass = [ $variantClass = match ($this->variant) {
'default' => 'shadow-sm text-black bg-gray-300 hover:bg-gray-400',
'primary' => 'shadow-sm text-accent-contrast bg-accent-base hover:bg-accent-hover', 'primary' => 'shadow-sm text-accent-contrast bg-accent-base hover:bg-accent-hover',
'secondary' => 'shadow-sm ring-2 ring-accent ring-inset text-accent-base bg-white hover:border-accent-hover hover:text-accent-hover', 'secondary' => 'shadow-sm ring-2 ring-accent ring-inset text-accent-base bg-white hover:border-accent-hover hover:text-accent-hover',
'success' => 'shadow-sm text-white bg-pine-500 hover:bg-pine-800', 'success' => 'shadow-sm ring-2 ring-pine-700 ring-inset text-pine-700 hover:ring-pine-800 hover:text-pine-800',
'danger' => 'shadow-sm text-white bg-red-600 hover:bg-red-700', 'danger' => 'shadow-sm ring-2 ring-red-700 ring-inset text-red-700 hover:ring-red-800 hover:text-red-800',
'warning' => 'shadow-sm text-black bg-yellow-500 hover:bg-yellow-600', 'warning' => 'shadow-sm ring-2 ring-yellow-700 ring-inset text-yellow-700 hover:ring-yellow-800 hover:text-yellow-800',
'info' => 'shadow-sm text-white bg-blue-500 hover:bg-blue-600', 'info' => 'shadow-sm ring-2 ring-blue-700 ring-inset text-blue-700 hover:ring-blue-800 hover:text-blue-800',
'disabled' => 'shadow-sm text-black bg-gray-300 cursor-not-allowed', 'disabled' => 'shadow-sm text-black bg-gray-300 cursor-not-allowed',
]; default => 'shadow-sm text-black bg-gray-100 hover:bg-gray-300',
};
$sizeClass = [ $sizeClass = match ($this->size) {
'small' => 'text-xs leading-6', 'small' => 'text-xs leading-6',
'base' => 'text-sm leading-5',
'large' => 'text-base leading-6', 'large' => 'text-base leading-6',
]; default => 'text-sm leading-5',
};
$iconSize = [ $iconSizeClass = match ($this->size) {
'small' => 'text-sm', 'small' => 'text-sm',
'base' => 'text-lg',
'large' => 'text-2xl', 'large' => 'text-2xl',
]; default => 'text-lg',
};
$basePaddings = [ $basePaddings = match ($this->size) {
'small' => 'px-3 py-1', 'small' => 'px-3 py-1',
'base' => 'px-3 py-2',
'large' => 'px-4 py-2', 'large' => 'px-4 py-2',
]; default => 'px-3 py-2',
};
$squaredPaddings = [ $squaredPaddings = match ($this->size) {
'small' => 'p-1', 'small' => 'p-1',
'base' => 'p-2',
'large' => 'p-3', 'large' => 'p-3',
]; default => 'p-2',
};
$buttonClass = $this->mergeClass($variantClass);
$baseClass . $this->mergeClass($sizeClass);
' ' .
($this->isSquared
? $squaredPaddings[$this->size]
: $basePaddings[$this->size]) .
' ' .
$sizeClass[$this->size] .
' ' .
$variantClass[$this->variant];
if (array_key_exists('class', $this->attributes)) { if ($this->isSquared) {
$buttonClass .= ' ' . $this->attributes['class']; $this->mergeClass($squaredPaddings);
unset($this->attributes['class']); } else {
$this->mergeClass($basePaddings);
} }
if ($this->iconLeft !== '' || $this->iconRight !== '') { if ($this->iconLeft !== '' || $this->iconRight !== '') {
@ -87,41 +86,30 @@ class Button extends Component
if ($this->iconLeft !== '') { if ($this->iconLeft !== '') {
$this->slot = icon($this->iconLeft, [ $this->slot = icon($this->iconLeft, [
'class' => 'opacity-75 ' . $iconSize[$this->size], 'class' => 'opacity-75 ' . $iconSizeClass,
]) . $this->slot; ]) . $this->slot;
} }
if ($this->iconRight !== '') { if ($this->iconRight !== '') {
$this->slot .= icon($this->iconRight, [ $this->slot .= icon($this->iconRight, [
'class' => 'opacity-75 ' . $iconSize[$this->size], 'class' => 'opacity-75 ' . $iconSizeClass,
]); ]);
} }
unset($this->attributes['slot']);
unset($this->attributes['variant']);
unset($this->attributes['size']);
unset($this->attributes['iconLeft']);
unset($this->attributes['iconRight']);
unset($this->attributes['isSquared']);
unset($this->attributes['uri']);
unset($this->attributes['label']);
if ($this->uri !== '') { if ($this->uri !== '') {
$tagName = 'a'; $tagName = 'a';
$defaultButtonAttributes = [ $this->attributes['href'] = $this->uri;
'href' => $this->uri, if ($this->isExternal) {
]; $this->attributes['target'] = '_blank';
$this->attributes['rel'] = 'noopener noreferrer';
}
} else { } else {
$tagName = 'button'; $tagName = 'button';
$defaultButtonAttributes = [ $this->attributes['type'] ??= 'button';
'type' => 'button',
];
} }
$attributes = stringify_attributes(array_merge($defaultButtonAttributes, $this->attributes));
return <<<HTML return <<<HTML
<{$tagName} class="{$buttonClass}" {$attributes}>{$this->slot}</{$tagName}> <{$tagName} {$this->getStringifiedAttributes()}>{$this->slot}</{$tagName}>
HTML; HTML;
} }
} }

View File

@ -8,13 +8,13 @@ use ViewComponents\Component;
class ChartsComponent extends Component class ChartsComponent extends Component
{ {
protected string $title = ''; protected string $title;
protected string $subtitle = ''; protected string $subtitle = '';
protected string $dataUrl = ''; protected string $dataUrl;
protected string $type = ''; protected string $type;
public function render(): string public function render(): string
{ {
@ -23,8 +23,10 @@ class ChartsComponent extends Component
$subtitleBlock = '<p class="px-6 -mt-4 text-sm text-skin-muted">' . $this->subtitle . '</p>'; $subtitleBlock = '<p class="px-6 -mt-4 text-sm text-skin-muted">' . $this->subtitle . '</p>';
} }
$this->mergeClass('bg-elevated border-3 rounded-xl border-subtle');
return <<<HTML return <<<HTML
<div class="bg-elevated border-3 rounded-xl border-subtle {$this->class}"> <div {$this->getStringifiedAttributes()}>
<h2 class="px-6 py-4 text-xl">{$this->title}</h2> <h2 class="px-6 py-4 text-xl">{$this->title}</h2>
{$subtitleBlock} {$subtitleBlock}
<div class="w-full h-[500px]" data-chart-type="{$this->type}" data-chart-url="{$this->dataUrl}"></div> <div class="w-full h-[500px]" data-chart-type="{$this->type}" data-chart-url="{$this->dataUrl}"></div>

View File

@ -8,7 +8,9 @@ use ViewComponents\Component;
class DashboardCard extends Component class DashboardCard extends Component
{ {
protected ?string $href = null; protected array $props = ['href', 'glyph', 'title', 'subtitle'];
protected string $href = '';
protected string $glyph; protected string $glyph;
@ -27,11 +29,11 @@ class DashboardCard extends Component
'class' => 'flex-shrink-0 bg-base rounded-full w-8 h-8 p-2 text-accent-base', 'class' => 'flex-shrink-0 bg-base rounded-full w-8 h-8 p-2 text-accent-base',
]); ]);
if ($this->href !== null && $this->href !== '') { if ($this->href !== '') {
$chevronRight = icon('arrow-right-s-fill'); $chevronRight = icon('arrow-right-s-fill');
$viewLang = lang('Common.view'); $viewLang = lang('Common.view');
return <<<HTML return <<<HTML
<a href="{$this->href}" class="flex items-center justify-between w-full gap-4 p-4 lg:max-w-sm lg:flex-col xl:flex-row bg-elevated focus:ring-accent rounded-xl border-3 border-subtle group"> <a href="{$this->href}" class="flex items-center justify-between w-full gap-4 p-4 lg:max-w-sm lg:flex-col xl:flex-row bg-elevated rounded-xl border-3 border-subtle group">
<div class="flex items-start">{$glyph}<div class="flex flex-col ml-2"><div class="flex items-center"><span class="text-xs font-semibold leading-loose tracking-wider uppercase">{$this->title}</span><div class="inline-flex items-center ml-4 transition -translate-x-full group-hover:translate-x-0 group-focus:translate-x-0"><span class="-ml-2 text-xs lowercase transition opacity-0 group-hover:opacity-100 group-focus:opacity-100">{$viewLang}</span>{$chevronRight}</div></div><p class="text-xs">{$this->subtitle}</p></div></div> <div class="flex items-start">{$glyph}<div class="flex flex-col ml-2"><div class="flex items-center"><span class="text-xs font-semibold leading-loose tracking-wider uppercase">{$this->title}</span><div class="inline-flex items-center ml-4 transition -translate-x-full group-hover:translate-x-0 group-focus:translate-x-0"><span class="-ml-2 text-xs lowercase transition opacity-0 group-hover:opacity-100 group-focus:opacity-100">{$viewLang}</span>{$chevronRight}</div></div><p class="text-xs">{$this->subtitle}</p></div></div>
<div class="text-5xl font-bold">{$this->slot}</div> <div class="text-5xl font-bold">{$this->slot}</div>
</a> </a>

View File

@ -9,17 +9,25 @@ use ViewComponents\Component;
class DropdownMenu extends Component class DropdownMenu extends Component
{ {
public string $id = ''; protected array $props = ['id', 'labelledby', 'placement', 'offsetX', 'offsetY', 'items'];
public string $labelledby; protected array $casts = [
'offsetX' => 'number',
'offsetY' => 'number',
'items' => 'array',
];
public string $placement = 'bottom-end'; protected string $id;
public string $offsetX = '0'; protected string $labelledby;
public string $offsetY = '0'; protected string $placement = 'bottom-end';
public array $items = []; protected int $offsetX = 0;
protected int $offsetY = 0;
protected array $items = [];
public function setItems(string $value): void public function setItems(string $value): void
{ {
@ -37,7 +45,7 @@ class DropdownMenu extends Component
switch ($item['type']) { switch ($item['type']) {
case 'link': case 'link':
$menuItems .= anchor($item['uri'], $item['title'], [ $menuItems .= anchor($item['uri'], $item['title'], [
'class' => 'inline-flex gap-x-1 items-center px-4 py-1 hover:bg-highlight focus:ring-accent focus:ring-inset' . (array_key_exists('class', $item) ? ' ' . $item['class'] : ''), 'class' => 'inline-flex gap-x-1 items-center px-4 py-1 hover:bg-highlight' . (array_key_exists('class', $item) ? ' ' . $item['class'] : ''),
]); ]);
break; break;
case 'html': case 'html':
@ -51,14 +59,16 @@ class DropdownMenu extends Component
} }
} }
$this->mergeClass('absolute flex flex-col py-2 rounded-lg z-60 whitespace-nowrap text-skin-base border-contrast bg-elevated border-3');
$this->attributes['id'] = $this->id;
$this->attributes['aria-labelledby'] = $this->labelledby;
$this->attributes['data-dropdown'] = 'menu';
$this->attributes['data-dropdown-placement'] = $this->placement;
$this->attributes['data-dropdown-offset-x'] = $this->offsetX;
$this->attributes['data-dropdown-offset-y'] = $this->offsetY;
return <<<HTML return <<<HTML
<nav id="{$this->id}" <nav {$this->getStringifiedAttributes()}>{$menuItems}</nav>
class="absolute flex flex-col py-2 rounded-lg z-60 whitespace-nowrap text-skin-base border-contrast bg-elevated border-3"
aria-labelledby="{$this->labelledby}"
data-dropdown="menu"
data-dropdown-placement="{$this->placement}"
data-dropdown-offset-x="{$this->offsetX}"
data-dropdown-offset-y="{$this->offsetY}">{$menuItems}</nav>
HTML; HTML;
} }
} }

View File

@ -4,35 +4,41 @@ declare(strict_types=1);
namespace App\Views\Components\Forms; namespace App\Views\Components\Forms;
use App\Views\Components\Hint;
class Checkbox extends FormComponent class Checkbox extends FormComponent
{ {
protected ?string $hint = null; protected array $props = ['hint', 'isChecked'];
protected array $casts = [
'isChecked' => 'boolean',
];
protected string $hint = '';
protected bool $isChecked = false; protected bool $isChecked = false;
public function setIsChecked(string $value): void
{
$this->isChecked = $value === 'true';
}
public function render(): string public function render(): string
{ {
$attributes = [
'id' => $this->value,
'name' => $this->name,
'class' => 'form-checkbox bg-elevated text-accent-base border-contrast border-3 focus:ring-accent w-6 h-6',
];
$checkboxInput = form_checkbox( $checkboxInput = form_checkbox(
$attributes, [
'id' => $this->value,
'name' => $this->name,
'class' => 'form-checkbox bg-elevated text-accent-base border-contrast border-3 w-6 h-6',
],
'yes', 'yes',
old($this->name) ? old($this->name) === $this->value : $this->isChecked, old($this->name) ? old($this->name) === $this->value : $this->isChecked,
); );
$hint = $this->hint === null ? '' : hint_tooltip($this->hint, 'ml-1'); $hint = $this->hint === '' ? '' : (new Hint([
'class' => 'ml-1',
'slot' => $this->hint,
]))->render();
$this->mergeClass('inline-flex items-center');
return <<<HTML return <<<HTML
<label class="inline-flex items-center {$this->class}">{$checkboxInput}<span class="ml-2">{$this->slot}{$hint}</span></label> <label {$this->getStringifiedAttributes()}>{$checkboxInput}<span class="ml-2">{$this->slot}{$hint}</span></label>
HTML; HTML;
} }
} }

View File

@ -6,15 +6,14 @@ namespace App\Views\Components\Forms;
class ColorRadioButton extends FormComponent class ColorRadioButton extends FormComponent
{ {
protected array $props = ['isChecked'];
protected array $casts = [
'isChecked' => 'boolean',
];
protected bool $isChecked = false; protected bool $isChecked = false;
protected string $style = '';
public function setIsChecked(string $value): void
{
$this->isChecked = $value === 'true';
}
public function render(): string public function render(): string
{ {
$data = [ $data = [
@ -23,7 +22,7 @@ class ColorRadioButton extends FormComponent
'class' => 'color-radio-btn', 'class' => 'color-radio-btn',
]; ];
if ($this->required) { if ($this->isRequired) {
$data['required'] = 'required'; $data['required'] = 'required';
} }
@ -34,7 +33,7 @@ class ColorRadioButton extends FormComponent
); );
return <<<HTML return <<<HTML
<div class="{$this->class}" style="{$this->style}"> <div {$this->getStringifiedAttributes()}>
{$radioInput} {$radioInput}
<label for="{$this->value}" title="{$this->slot}" data-tooltip="bottom"></label> <label for="{$this->value}" title="{$this->slot}" data-tooltip="bottom"></label>
</div> </div>

View File

@ -6,21 +6,28 @@ namespace App\Views\Components\Forms;
class DatetimePicker extends FormComponent class DatetimePicker extends FormComponent
{ {
protected array $attributes = [
'data-picker' => 'datetime',
];
public function render(): string public function render(): string
{ {
$this->attributes['class'] = 'rounded-l-lg border-0 border-rounded-r-none flex-1 focus:ring-0'; $dateInput = form_input([
$this->attributes['data-input'] = ''; 'class' => 'rounded-l-lg border-0 border-rounded-r-none flex-1 focus:ring-0',
$dateInput = form_input($this->attributes, old($this->name, $this->value)); 'data-input' => '',
], old($this->name, $this->value));
$clearLabel = lang( $clearLabel = lang(
'Episode.publish_form.scheduled_publication_date_clear', 'Episode.publish_form.scheduled_publication_date_clear',
); );
$closeIcon = icon('close-fill'); $closeIcon = icon('close-fill');
$this->mergeClass('flex border-3 rounded-lg border-contrast focus-within:ring-accent');
return <<<HTML return <<<HTML
<div class="flex border-3 rounded-lg border-contrast focus-within:ring-accent {$this->class}" data-picker="datetime"> <div {$this->getStringifiedAttributes()}>
{$dateInput} {$dateInput}
<button class="p-3 bg-elevated hover:bg-base rounded-r-md focus:ring-inset focus:ring-accent" type="button" aria-label="{$clearLabel}" title="{$clearLabel}" data-clear=""> <button class="p-3 bg-elevated hover:bg-base rounded-r-md focus:ring-inset" type="button" aria-label="{$clearLabel}" title="{$clearLabel}" data-clear="">
{$closeIcon} {$closeIcon}
</button> </button>
</div> </div>

View File

@ -4,49 +4,76 @@ declare(strict_types=1);
namespace App\Views\Components\Forms; namespace App\Views\Components\Forms;
class Field extends FormComponent use ViewComponents\Component;
class Field extends Component
{ {
protected array $props = [
'name',
'label',
'isRequired',
'isReadonly',
'as',
'helper',
'hint',
];
protected array $casts = [
'isRequired' => 'boolean',
'isReadonly' => 'boolean',
];
protected string $name;
protected string $label;
protected bool $isRequired = false;
protected bool $isReadonly = false;
protected string $as = 'Input'; protected string $as = 'Input';
protected string $label = ''; protected string $helper = '';
protected ?string $helper = null; protected string $hint = '';
protected ?string $hint = null;
public function render(): string public function render(): string
{ {
$helperText = ''; $helperText = '';
if ($this->helper !== null) { if ($this->helper !== '') {
$helperId = $this->id . 'Help'; $helperId = $this->name . 'Help';
$helperText = '<Forms.Helper id="' . $helperId . '">' . $this->helper . '</Forms.Helper>'; $helperText = (new Helper([
'id' => $helperId,
'slot' => $this->helper,
]))->render();
$this->attributes['aria-describedby'] = $helperId; $this->attributes['aria-describedby'] = $helperId;
} }
$labelAttributes = [ $labelAttributes = [
'for' => $this->id, 'for' => $this->name,
'isOptional' => $this->required ? 'false' : 'true', 'isOptional' => $this->isRequired ? 'false' : 'true',
'class' => '-mb-1', 'class' => '-mb-1',
'slot' => $this->label,
]; ];
if ($this->hint) { if ($this->hint !== '') {
$labelAttributes['hint'] = $this->hint; $labelAttributes['hint'] = $this->hint;
} }
$labelAttributes = stringify_attributes($labelAttributes); $label = new Label($labelAttributes);
// remove field specific attributes to inject the rest to Form Component $this->mergeClass('flex flex-col');
$fieldComponentAttributes = $this->attributes; $fieldClass = $this->attributes['class'];
unset($fieldComponentAttributes['as']);
unset($fieldComponentAttributes['label']);
unset($fieldComponentAttributes['class']);
unset($fieldComponentAttributes['helper']);
unset($fieldComponentAttributes['hint']);
unset($this->attributes['class']);
$this->attributes['name'] = $this->name;
$this->attributes['isRequired'] = $this->isRequired ? 'true' : 'false';
$this->attributes['isReadonly'] = $this->isReadonly ? 'true' : 'false';
$element = __NAMESPACE__ . '\\' . $this->as; $element = __NAMESPACE__ . '\\' . $this->as;
$fieldElement = new $element($fieldComponentAttributes); $fieldElement = new $element($this->attributes);
return <<<HTML return <<<HTML
<div class="flex flex-col {$this->class}"> <div class="{$fieldClass}">
<Forms.Label {$labelAttributes}>{$this->label}</Forms.Label> {$label->render()}
{$helperText} {$helperText}
<div class="w-full mt-1"> <div class="w-full mt-1">
{$fieldElement->render()} {$fieldElement->render()}

View File

@ -6,28 +6,55 @@ namespace App\Views\Components\Forms;
use ViewComponents\Component; use ViewComponents\Component;
class FormComponent extends Component abstract class FormComponent extends Component
{ {
protected ?string $id = null; protected array $props = [
'id',
'name',
'value',
'isRequired',
'isReadonly',
];
protected string $name = ''; protected array $casts = [
'isRequired' => 'boolean',
'isReadonly' => 'boolean',
];
protected string $id;
protected string $name;
protected string $value = ''; protected string $value = '';
protected bool $required = false; protected bool $isRequired = false;
protected bool $readonly = false; protected bool $isReadonly = false;
/** /**
* @param array<string, string> $attributes * @param array<string, string> $attributes
*/ */
public function __construct(array $attributes) public function __construct(array $attributes)
{ {
$parentVars = get_class_vars(self::class);
$this->casts = [...$parentVars['casts'], ...$this->casts];
$this->props = [...$parentVars['props'], $this->props];
parent::__construct($attributes); parent::__construct($attributes);
if ($this->id === null) { if (! isset($this->id)) {
$this->id = $this->name; $this->id = $this->name;
$this->attributes['id'] = $this->id; }
$this->attributes['id'] = $this->id;
$this->attributes['name'] = $this->name;
if ($this->isRequired) {
$this->attributes['required'] = 'required';
}
if ($this->isReadonly) {
$this->attributes['readonly'] = 'readonly';
} }
} }
@ -35,23 +62,4 @@ class FormComponent extends Component
{ {
$this->value = htmlspecialchars_decode($value, ENT_QUOTES); $this->value = htmlspecialchars_decode($value, ENT_QUOTES);
} }
public function setRequired(string $value): void
{
$this->required = $value === 'true';
unset($this->attributes['required']);
if ($this->required) {
$this->attributes['required'] = 'required';
}
}
public function setReadonly(string $value): void
{
$this->readonly = $value === 'true';
if ($this->readonly) {
$this->attributes['readonly'] = 'readonly';
} else {
unset($this->attributes['readonly']);
}
}
} }

View File

@ -4,19 +4,18 @@ declare(strict_types=1);
namespace App\Views\Components\Forms; namespace App\Views\Components\Forms;
class Helper extends FormComponent use ViewComponents\Component;
class Helper extends Component
{ {
/** // TODO: add type with error and show errors inline
* @var 'default'|'error'
*/
protected string $type = 'default';
public function render(): string public function render(): string
{ {
$class = 'text-skin-muted'; $this->mergeClass('text-skin-muted');
return <<<HTML return <<<HTML
<small id="{$this->id}" class="{$class} {$this->class}">{$this->slot}</small> <small {$this->getStringifiedAttributes()}>{$this->slot}</small>
HTML; HTML;
} }
} }

View File

@ -6,26 +6,29 @@ namespace App\Views\Components\Forms;
class Input extends FormComponent class Input extends FormComponent
{ {
protected array $props = ['type'];
protected string $type = 'text'; protected string $type = 'text';
public function render(): string public function render(): string
{ {
$baseClass = 'w-full border-contrast rounded-lg focus:border-contrast border-3 focus:ring-accent focus-within:ring-accent ' . $this->class; $this->mergeClass('w-full border-contrast rounded-lg focus:border-contrast border-3 focus-within:ring-accent');
$this->attributes['class'] = $baseClass;
if ($this->type === 'file') { if ($this->type === 'file') {
$this->attributes['class'] .= ' file:px-3 file:py-2 file:h-[40px] file:font-semibold file:text-skin-muted file:text-sm file:rounded-none file:border-none file:bg-highlight file:cursor-pointer'; $this->mergeClass('file:px-3 file:py-2 file:h-[40px] file:font-semibold file:text-skin-muted file:text-sm file:rounded-none file:border-none file:bg-highlight file:cursor-pointer');
} else { } else {
$this->attributes['class'] .= ' px-3 py-2'; $this->mergeClass('px-3 py-2');
} }
if ($this->readonly) { if ($this->isReadonly) {
$this->attributes['class'] .= ' bg-base'; $this->mergeClass('bg-base');
} else { } else {
$this->attributes['class'] .= ' bg-elevated'; $this->mergeClass('bg-elevated');
} }
$this->attributes['type'] = $this->type;
$this->attributes['value'] = $this->value;
return form_input($this->attributes, old($this->name, $this->value)); return form_input($this->attributes, old($this->name, $this->value));
} }
} }

View File

@ -4,39 +4,38 @@ declare(strict_types=1);
namespace App\Views\Components\Forms; namespace App\Views\Components\Forms;
use App\Views\Components\Hint;
use ViewComponents\Component; use ViewComponents\Component;
class Label extends Component class Label extends Component
{ {
protected ?string $for = null; protected array $props = ['for', 'hint', 'isOptional'];
protected ?string $hint = null; protected array $casts = [
'isOptional' => 'boolean',
];
protected string $for;
protected string $hint = '';
protected bool $isOptional = false; protected bool $isOptional = false;
public function setIsOptional(string $value): void
{
$this->isOptional = $value === 'true';
}
public function render(): string public function render(): string
{ {
$labelClass = 'text-sm font-semibold ' . $this->attributes['class']; $this->mergeClass('text-sm font-semibold');
unset($this->attributes['class']);
$optionalText = $this->isOptional ? '<small class="ml-1 font-normal lowercase">(' . $optionalText = $this->isOptional ? '<small class="ml-1 font-normal lowercase">(' .
lang('Common.optional') . lang('Common.optional') .
')</small>' : ''; ')</small>' : '';
$hint = $this->hint === null ? '' : hint_tooltip($this->hint, 'ml-1');
unset($this->attributes['isOptional']); $hint = $this->hint === '' ? '' : (new Hint([
unset($this->attributes['hint']); 'class' => 'ml-1',
unset($this->attributes['slot']); 'slot' => $this->hint,
]))->render();
$attributes = stringify_attributes($this->attributes);
return <<<HTML return <<<HTML
<label class="{$labelClass}" {$attributes}>{$this->slot}{$optionalText}{$hint}</label> <label {$this->getStringifiedAttributes()}>{$this->slot}{$optionalText}{$hint}</label>
HTML; HTML;
} }
} }

View File

@ -6,6 +6,8 @@ namespace App\Views\Components\Forms;
class MarkdownEditor extends FormComponent class MarkdownEditor extends FormComponent
{ {
protected array $props = ['disallowList'];
/** /**
* @var string[] * @var string[]
*/ */
@ -18,18 +20,20 @@ class MarkdownEditor extends FormComponent
public function render(): string public function render(): string
{ {
$editorClass = 'w-full flex flex-col bg-elevated border-3 border-contrast rounded-lg overflow-hidden focus-within:ring-accent ' . $this->class; $this->mergeClass('w-full flex flex-col bg-elevated border-3 border-contrast rounded-lg overflow-hidden focus-within:ring-accent');
$wrapperClass = $this->attributes['class'];
$this->attributes['class'] = 'bg-elevated border-none focus:border-none focus:outline-none focus:ring-0 w-full h-full'; $this->attributes['class'] = 'bg-elevated border-none focus:border-none focus:outline-none focus:ring-0 w-full h-full';
$this->attributes['rows'] = 6; $this->attributes['rows'] = 6;
$textarea = form_textarea($this->attributes, old($this->name, $this->value)); $textarea = form_textarea(
$markdownIcon = icon( $this->attributes,
'markdown-fill', old($this->name, $this->value)
[
'class' => 'mr-1 text-lg opacity-40',
]
); );
$markdownIcon = icon('markdown-fill', [
'class' => 'mr-1 text-lg opacity-40',
]);
$translations = [ $translations = [
'write' => lang('Common.forms.editor.write'), 'write' => lang('Common.forms.editor.write'),
'preview' => lang('Common.forms.editor.preview'), 'preview' => lang('Common.forms.editor.preview'),
@ -85,19 +89,19 @@ class MarkdownEditor extends FormComponent
$toolbarContent .= '<div class="inline-flex text-2xl gap-x-1">'; $toolbarContent .= '<div class="inline-flex text-2xl gap-x-1">';
foreach ($buttonsGroup as $button) { foreach ($buttonsGroup as $button) {
if (! in_array($button['name'], $this->disallowList, true)) { if (! in_array($button['name'], $this->disallowList, true)) {
$toolbarContent .= '<' . $button['tag'] . ' class="opacity-50 hover:opacity-100 focus:ring-accent focus:opacity-100">' . $button['icon'] . '</' . $button['tag'] . '>'; $toolbarContent .= '<' . $button['tag'] . ' class="opacity-50 hover:opacity-100 focus:opacity-100">' . $button['icon'] . '</' . $button['tag'] . '>';
} }
} }
$toolbarContent .= '</div>'; $toolbarContent .= '</div>';
} }
return <<<HTML return <<<HTML
<div class="{$editorClass}"> <div class="{$wrapperClass}">
<header class="px-2"> <header class="px-2">
<div class="sticky top-0 z-20 flex flex-wrap justify-between border-b border-gray-300 bg-elevated"> <div class="sticky top-0 z-20 flex flex-wrap justify-between border-b border-gray-300 bg-elevated">
<markdown-write-preview for="{$this->id}" class="relative inline-flex h-8"> <markdown-write-preview for="{$this->id}" class="relative inline-flex h-8">
<button type="button" slot="write" class="px-2 font-semibold focus:ring-inset focus:ring-accent">{$translations['write']}</button> <button type="button" slot="write" class="px-2 font-semibold">{$translations['write']}</button>
<button type="button" slot="preview" class="px-2 font-semibold focus:ring-inset focus:ring-accent">{$translations['preview']}</button> <button type="button" slot="preview" class="px-2 font-semibold">{$translations['preview']}</button>
</markdown-write-preview> </markdown-write-preview>
<markdown-toolbar for="{$this->id}" class="flex gap-4 px-2 py-1">{$toolbarContent}</markdown-toolbar> <markdown-toolbar for="{$this->id}" class="flex gap-4 px-2 py-1">{$toolbarContent}</markdown-toolbar>
</div> </div>

View File

@ -6,6 +6,13 @@ namespace App\Views\Components\Forms;
class MultiSelect extends FormComponent class MultiSelect extends FormComponent
{ {
protected array $props = ['options', 'selected'];
protected array $casts = [
'options' => 'array',
'selected' => 'array',
];
/** /**
* @var array<string, string> * @var array<string, string>
*/ */
@ -16,18 +23,10 @@ class MultiSelect extends FormComponent
*/ */
protected array $selected = []; protected array $selected = [];
public function setOptions(string $value): void
{
$this->options = json_decode(htmlspecialchars_decode($value), true);
}
public function setSelected(string $selected): void
{
$this->selected = json_decode(htmlspecialchars_decode($selected), true);
}
public function render(): string public function render(): string
{ {
$this->mergeClass('w-full bg-elevated border-3 border-contrast rounded-lg');
$defaultAttributes = [ $defaultAttributes = [
'data-class' => $this->attributes['class'], 'data-class' => $this->attributes['class'],
'multiple' => 'multiple', 'multiple' => 'multiple',
@ -37,8 +36,7 @@ class MultiSelect extends FormComponent
'data-no-choices-text' => lang('Common.forms.multiSelect.noChoicesText'), 'data-no-choices-text' => lang('Common.forms.multiSelect.noChoicesText'),
'data-max-item-text' => lang('Common.forms.multiSelect.maxItemText'), 'data-max-item-text' => lang('Common.forms.multiSelect.maxItemText'),
]; ];
$this->attributes['class'] .= ' w-full bg-elevated border-3 border-contrast rounded-lg'; $extra = [...$defaultAttributes, ...$this->attributes];
$extra = array_merge($defaultAttributes, $this->attributes);
return form_dropdown($this->name, $this->options, $this->selected, $extra); return form_dropdown($this->name, $this->options, $this->selected, $extra);
} }

View File

@ -6,12 +6,13 @@ namespace App\Views\Components\Forms;
class Radio extends FormComponent class Radio extends FormComponent
{ {
protected bool $isChecked = false; protected array $props = ['isChecked'];
public function setIsChecked(string $value): void protected array $casts = [
{ 'isChecked' => 'boolean',
$this->isChecked = $value === 'true'; ];
}
protected bool $isChecked = false;
public function render(): string public function render(): string
{ {
@ -19,14 +20,16 @@ class Radio extends FormComponent
[ [
'id' => $this->value, 'id' => $this->value,
'name' => $this->name, 'name' => $this->name,
'class' => 'text-accent-base bg-elevated border-contrast border-3 focus:ring-accent w-6 h-6', 'class' => 'text-accent-base bg-elevated border-contrast border-3 w-6 h-6',
], ],
$this->value, $this->value,
old($this->name) ? old($this->name) === $this->value : $this->isChecked, old($this->name) ? old($this->name) === $this->value : $this->isChecked,
); );
$this->mergeClass('inline-flex items-center');
return <<<HTML return <<<HTML
<label class="inline-flex items-center {$this->class}">{$radioInput}<span class="ml-2">{$this->slot}</span></label> <label {$this->getStringifiedAttributes()}>{$radioInput}<span class="ml-2">{$this->slot}</span></label>
HTML; HTML;
} }
} }

View File

@ -4,16 +4,19 @@ declare(strict_types=1);
namespace App\Views\Components\Forms; namespace App\Views\Components\Forms;
use App\Views\Components\Hint;
class RadioButton extends FormComponent class RadioButton extends FormComponent
{ {
protected array $props = ['isChecked', 'hint'];
protected array $casts = [
'isChecked' => 'boolean',
];
protected bool $isChecked = false; protected bool $isChecked = false;
protected ?string $hint = null; protected string $hint = '';
public function setIsChecked(string $value): void
{
$this->isChecked = $value === 'true';
}
public function render(): string public function render(): string
{ {
@ -23,7 +26,7 @@ class RadioButton extends FormComponent
'class' => 'form-radio-btn bg-elevated', 'class' => 'form-radio-btn bg-elevated',
]; ];
if ($this->required) { if ($this->isRequired) {
$data['required'] = 'required'; $data['required'] = 'required';
} }
@ -33,10 +36,13 @@ class RadioButton extends FormComponent
old($this->name) ? old($this->name) === $this->value : $this->isChecked, old($this->name) ? old($this->name) === $this->value : $this->isChecked,
); );
$hint = $this->hint ? hint_tooltip($this->hint, 'ml-1 text-base') : ''; $hint = $this->hint === '' ? '' : (new Hint([
'class' => 'ml-1 text-base',
'slot' => $this->hint,
]))->render();
return <<<HTML return <<<HTML
<div class="{$this->class}"> <div {$this->getStringifiedAttributes()}">
{$radioInput} {$radioInput}
<label for="{$this->value}">{$this->slot}{$hint}</label> <label for="{$this->value}">{$this->slot}{$hint}</label>
</div> </div>

View File

@ -8,17 +8,21 @@ use ViewComponents\Component;
class Section extends Component class Section extends Component
{ {
protected string $title = ''; protected array $props = ['title', 'subtitle'];
protected ?string $subtitle = null; protected string $title;
protected string $subtitle = '';
public function render(): string public function render(): string
{ {
$subtitle = $this->subtitle === null ? '' : '<p class="text-sm text-skin-muted">' . $this->subtitle . '</p>'; $subtitle = $this->subtitle === '' ? '' : '<p class="text-sm text-skin-muted">' . $this->subtitle . '</p>';
$this->mergeClass('w-full p-8 bg-elevated border-3 flex flex-col items-start border-subtle rounded-xl');
return <<<HTML return <<<HTML
<fieldset class="w-full p-8 bg-elevated border-3 flex flex-col items-start border-subtle rounded-xl {$this->class}"> <fieldset {$this->getStringifiedAttributes()}>
<Heading tagName="legend" class="float-left">{$this->title}</Heading> <x-Heading tagName="legend" class="float-left">{$this->title}</x-Heading>
{$subtitle} {$subtitle}
<div class="flex flex-col w-0 min-w-full gap-4 py-4">{$this->slot}</div> <div class="flex flex-col w-0 min-w-full gap-4 py-4">{$this->slot}</div>
</fieldset> </fieldset>

View File

@ -6,6 +6,12 @@ namespace App\Views\Components\Forms;
class Select extends FormComponent class Select extends FormComponent
{ {
protected array $props = ['options', 'selected'];
protected array $casts = [
'options' => 'array',
];
/** /**
* @var array<string, string> * @var array<string, string>
*/ */
@ -13,26 +19,17 @@ class Select extends FormComponent
protected string $selected = ''; protected string $selected = '';
public function setOptions(string $value): void
{
$this->options = json_decode(htmlspecialchars_decode($value), true);
}
public function render(): string public function render(): string
{ {
$this->mergeClass('w-full focus:border-contrast border-3 rounded-lg bg-elevated border-contrast');
$defaultAttributes = [ $defaultAttributes = [
'class' => 'w-full focus:border-contrast focus:ring-accent border-3 rounded-lg bg-elevated border-contrast ' . $this->class,
'data-class' => $this->class,
'data-select-text' => lang('Common.forms.multiSelect.selectText'), 'data-select-text' => lang('Common.forms.multiSelect.selectText'),
'data-loading-text' => lang('Common.forms.multiSelect.loadingText'), 'data-loading-text' => lang('Common.forms.multiSelect.loadingText'),
'data-no-results-text' => lang('Common.forms.multiSelect.noResultsText'), 'data-no-results-text' => lang('Common.forms.multiSelect.noResultsText'),
'data-no-choices-text' => lang('Common.forms.multiSelect.noChoicesText'), 'data-no-choices-text' => lang('Common.forms.multiSelect.noChoicesText'),
'data-max-item-text' => lang('Common.forms.multiSelect.maxItemText'), 'data-max-item-text' => lang('Common.forms.multiSelect.maxItemText'),
]; ];
unset($this->attributes['name']); $extra = [...$defaultAttributes, ...$this->attributes];
unset($this->attributes['options']);
unset($this->attributes['selected']);
$extra = [...$this->attributes, ...$defaultAttributes];
return form_dropdown($this->name, $this->options, old($this->name, $this->selected !== '' ? [$this->selected] : []), $extra); return form_dropdown($this->name, $this->options, old($this->name, $this->selected !== '' ? [$this->selected] : []), $extra);
} }

View File

@ -15,9 +15,9 @@ class Textarea extends FormComponent
public function render(): string public function render(): string
{ {
unset($this->attributes['value']); $this->mergeClass('bg-elevated w-full rounded-lg border-3 border-contrast focus:border-contrast focus-within:ring-accent');
$this->attributes['class'] = 'bg-elevated w-full focus:border-contrast focus:ring-accent rounded-lg border-3 border-contrast ' . $this->class; $this->attributes['id'] = $this->id;
$textarea = form_textarea( $textarea = form_textarea(
$this->attributes, $this->attributes,

View File

@ -4,45 +4,48 @@ declare(strict_types=1);
namespace App\Views\Components\Forms; namespace App\Views\Components\Forms;
use App\Views\Components\Hint;
class Toggler extends FormComponent class Toggler extends FormComponent
{ {
protected array $props = ['size', 'hint', 'isChecked'];
protected array $casts = [
'isChecked' => 'boolean',
];
/** /**
* @var 'base'|'small * @var 'base'|'small
*/ */
protected string $size = 'base'; protected string $size = 'base';
protected string $label = '';
protected string $hint = ''; protected string $hint = '';
protected bool $checked = false; protected bool $isChecked = false;
public function setChecked(string $value): void
{
$this->checked = $value === 'true';
}
public function render(): string public function render(): string
{ {
unset($this->attributes['checked']); $sizeClass = match ($this->size) {
$wrapperClass = $this->class;
unset($this->attributes['class']);
$sizeClass = [
'base' => 'form-switch-slider',
'small' => 'form-switch-slider form-switch-slider--small', 'small' => 'form-switch-slider form-switch-slider--small',
]; default => 'form-switch-slider',
};
$this->attributes['class'] = 'form-switch'; $this->mergeClass('relative justify-between inline-flex items-center gap-x-2');
$checkbox = form_checkbox([
'class' => 'form-switch',
], 'yes', old($this->name) === 'yes' ? true : $this->isChecked);
$hint = $this->hint === '' ? '' : (new Hint([
'class' => 'ml-1',
'slot' => $this->hint,
]))->render();
$checkbox = form_checkbox($this->attributes, $this->value, old($this->name) === 'yes' ? true : $this->checked);
$hint = $this->hint === '' ? '' : hint_tooltip($this->hint, 'ml-1');
return <<<HTML return <<<HTML
<label class="relative inline-flex items-center {$wrapperClass}"> <label {$this->getStringifiedAttributes()}>
<span class="">{$this->slot}{$hint}</span>
{$checkbox} {$checkbox}
<span class="{$sizeClass[$this->size]}"></span> <span class="{$sizeClass}"></span>
<span class="ml-2">{$this->slot}{$hint}</span>
</label> </label>
HTML; HTML;
} }

View File

@ -6,6 +6,8 @@ namespace App\Views\Components\Forms;
class XMLEditor extends FormComponent class XMLEditor extends FormComponent
{ {
protected array $props = ['content'];
/** /**
* @var array<string, string> * @var array<string, string>
*/ */

View File

@ -8,6 +8,8 @@ use ViewComponents\Component;
class Heading extends Component class Heading extends Component
{ {
protected array $props = ['tagName', 'size'];
protected string $tagName = 'div'; protected string $tagName = 'div';
/** /**
@ -17,16 +19,17 @@ class Heading extends Component
public function render(): string public function render(): string
{ {
$sizeClasses = [ $sizeClass = match ($this->size) {
'small' => 'tracking-wide text-base', 'small' => 'tracking-wide text-base',
'base' => 'text-xl',
'large' => 'text-3xl', 'large' => 'text-3xl',
]; default => 'text-xl',
};
$class = $this->class . ' relative z-10 font-bold text-heading-foreground font-display before:w-full before:absolute before:h-1/2 before:left-0 before:bottom-0 before:rounded-full before:bg-heading-background before:z-[-10] ' . $sizeClasses[$this->size]; $this->mergeClass('relative z-10 font-bold text-heading-foreground font-display before:w-full before:absolute before:h-1/2 before:left-0 before:bottom-0 before:rounded-full before:bg-heading-background before:z-[-10]');
$this->mergeClass($sizeClass);
return <<<HTML return <<<HTML
<{$this->tagName} class="{$class}">{$this->slot}</{$this->tagName}> <{$this->tagName} {$this->getStringifiedAttributes()}>{$this->slot}</{$this->tagName}>
HTML; HTML;
} }
} }

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Views\Components;
use ViewComponents\Component;
class Hint extends Component
{
protected array $attributes = [
'data-tooltip' => 'bottom',
'tabindex' => '0',
];
public function render(): string
{
$this->attributes['title'] = $this->slot;
$this->mergeClass('inline-block align-middle opacity-75');
$icon = icon('question-fill');
return <<<HTML
<span {$this->getStringifiedAttributes()}>{$icon}</span>
HTML;
}
}

View File

@ -6,7 +6,9 @@ namespace App\Views\Components;
class IconButton extends Button class IconButton extends Button
{ {
public string $glyph = ''; public string $glyph;
protected array $props = ['glyph'];
public function __construct(array $attributes) public function __construct(array $attributes)
{ {
@ -16,18 +18,18 @@ class IconButton extends Button
'data-tooltip' => 'bottom', 'data-tooltip' => 'bottom',
]; ];
$glyphSize = [
'small' => 'text-sm',
'base' => 'text-lg',
'large' => 'text-2xl',
];
$allAttributes = [...$attributes, ...$iconButtonAttributes]; $allAttributes = [...$attributes, ...$iconButtonAttributes];
parent::__construct($allAttributes); parent::__construct($allAttributes);
$glyphSizeClass = match ($this->size) {
'small' => 'text-sm',
'large' => 'text-2xl',
default => 'text-lg',
};
$this->slot = icon($this->glyph, [ $this->slot = icon($this->glyph, [
'class' => $glyphSize[$this->size], 'class' => $glyphSizeClass,
]); ]);
} }
} }

View File

@ -15,29 +15,44 @@ class Pill extends Component
public string $variant = 'default'; public string $variant = 'default';
public ?string $icon = null; public string $icon = '';
public ?string $iconClass = ''; public string $iconClass = '';
protected ?string $hint = null; protected array $props = ['size', 'variant', 'icon', 'iconClass', 'hint'];
protected string $hint = '';
public function render(): string public function render(): string
{ {
$variantClasses = [ $variantClass = match ($this->variant) {
'default' => 'text-gray-800 bg-gray-100 border-gray-300',
'primary' => 'text-accent-contrast bg-accent-base border-accent-base', 'primary' => 'text-accent-contrast bg-accent-base border-accent-base',
'success' => 'text-pine-900 bg-pine-100 border-pine-300', 'success' => 'text-pine-900 bg-pine-100 border-pine-300',
'danger' => 'text-red-900 bg-red-100 border-red-300', 'danger' => 'text-red-900 bg-red-100 border-red-300',
'warning' => 'text-yellow-900 bg-yellow-100 border-yellow-300', 'warning' => 'text-yellow-900 bg-yellow-100 border-yellow-300',
]; default => 'text-gray-800 bg-gray-100 border-gray-300',
};
$icon = $this->icon ? icon($this->icon, [ $sizeClass = match ($this->size) {
'small' => 'text-xs tracking-wide',
default => 'text-sm',
};
$icon = $this->icon !== '' ? icon($this->icon, [
'class' => $this->iconClass, 'class' => $this->iconClass,
]) : ''; ]) : '';
$hint = $this->hint ? 'data-tooltip="bottom" title="' . $this->hint . '"' : '';
if ($this->hint !== '') {
$this->attributes['data-tooltip'] = 'bottom';
$this->attributes['title'] = $this->hint;
}
$this->mergeClass('inline-flex lowercase items-center gap-x-1 px-1 font-semibold border rounded');
$this->mergeClass($variantClass);
$this->mergeClass($sizeClass);
return <<<HTML return <<<HTML
<span class="inline-flex items-center gap-x-1 px-1 font-semibold text-sm border rounded {$variantClasses[$this->variant]} {$this->class}" {$hint}>{$icon}{$this->slot}</span> <span {$this->getStringifiedAttributes()}>{$icon}{$this->slot}</span>
HTML; HTML;
} }
} }

View File

@ -8,17 +8,23 @@ use ViewComponents\Component;
class ReadMore extends Component class ReadMore extends Component
{ {
public string $id; protected array $props = ['id'];
protected string $id;
public function render(): string public function render(): string
{ {
$readMoreLabel = lang('Common.read_more'); $readMoreLabel = lang('Common.read_more');
$readLessLabel = lang('Common.read_less'); $readLessLabel = lang('Common.read_less');
$this->mergeClass('read-more');
$this->attributes['style'] = '--line-clamp: 3';
return <<<HTML return <<<HTML
<div class="read-more {$this->class}" style="--line-clamp: 3"> <div {$this->getStringifiedAttributes()}>
<input id="read-more-checkbox_{$this->id}" type="checkbox" class="read-more__checkbox" aria-hidden="true"> <input id="read-more-checkbox_{$this->id}" type="checkbox" class="read-more__checkbox" aria-hidden="true">
<div class="mb-2 read-more__text">{$this->slot}</div> <div class="read-more__text">{$this->slot}</div>
<label for="read-more-checkbox_{$this->id}" class="read-more__label" data-read-more="{$readMoreLabel}" data-read-less="{$readLessLabel}" aria-hidden="true"></label> <label for="read-more-checkbox_{$this->id}" class="mt-2 read-more__label" data-read-more="{$readMoreLabel}" data-read-less="{$readLessLabel}" aria-hidden="true"></label>
</div> </div>
HTML; HTML;
} }

View File

@ -12,11 +12,15 @@ class SeeMore extends Component
{ {
$seeMoreLabel = lang('Common.see_more'); $seeMoreLabel = lang('Common.see_more');
$seeLessLabel = lang('Common.see_less'); $seeLessLabel = lang('Common.see_less');
$this->mergeClass('see-more');
$this->attributes['styles'] = '--content-height: 10rem';
return <<<HTML return <<<HTML
<div class="see-more" style="--content-height: 10rem"> <div {$this->getStringifiedAttributes()}>
<input id="see-more-checkbox" type="checkbox" class="see-more__checkbox" aria-hidden="true"> <input id="see-more-checkbox" type="checkbox" class="see-more__checkbox" aria-hidden="true">
<div class="mb-2 see-more__content {$this->class}"><div class="see-more_content-fade"></div>{$this->slot}</div> <div class="see-more__content"><div class="see-more_content-fade"></div>{$this->slot}</div>
<label for="see-more-checkbox" class="see-more__label" data-see-more="{$seeMoreLabel}" data-see-less="{$seeLessLabel}" aria-hidden="true"></label> <label for="see-more-checkbox" class="mt-2 see-more__label" data-see-more="{$seeMoreLabel}" data-see-less="{$seeLessLabel}" aria-hidden="true"></label>
</div> </div>
HTML; HTML;
} }

View File

@ -1,18 +1,18 @@
<?php declare(strict_types=1); <?php declare(strict_types=1);
if (session()->has('message')): ?> if (session()->has('message')): ?>
<Alert variant="success" class="mb-4"><?= esc(session('message')) ?></Alert> <Alert variant="success" class="mb-4"><?= session('message') ?></Alert>
<?php endif; ?> <?php endif; ?>
<?php if (session()->has('error')): ?> <?php if (session()->has('error')): ?>
<Alert variant="danger" class="mb-4"><?= esc(session('error')) ?></Alert> <Alert variant="danger" class="mb-4"><?= session('error') ?></Alert>
<?php endif; ?> <?php endif; ?>
<?php if (session()->has('errors')): ?> <?php if (session()->has('errors')): ?>
<Alert variant="danger" class="mb-4"> <Alert variant="danger" class="mb-4">
<ul> <ul>
<?php foreach (session('errors') as $error): ?> <?php foreach (session('errors') as $error): ?>
<li><?= esc($error) ?></li> <li><?= $error ?></li>
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>
</Alert> </Alert>

View File

@ -23,7 +23,7 @@
You do not have sufficient permissions to access that page. You do not have sufficient permissions to access that page.
<?php endif; ?> <?php endif; ?>
</p> </p>
<a href="<?= previous_url() ?>" class="inline-flex items-center justify-center px-3 py-1 text-sm font-semibold rounded-full shadow-xs text-accent-contrast focus:ring-accent md:px-4 md:py-2 md:text-base bg-accent-base hover:bg-accent-hover"><?= lang('Common.go_back') ?></a> <a href="<?= previous_url() ?>" class="inline-flex items-center justify-center px-3 py-1 text-sm font-semibold rounded-full shadow-xs text-accent-contrast md:px-4 md:py-2 md:text-base bg-accent-base hover:bg-accent-hover"><?= lang('Common.go_back') ?></a>
</body> </body>
</html> </html>

View File

@ -23,7 +23,7 @@
<?= lang('Errors.sorryCannotFind') ?> <?= lang('Errors.sorryCannotFind') ?>
<?php endif; ?> <?php endif; ?>
</p> </p>
<a href="<?= previous_url() ?>" class="inline-flex items-center justify-center px-3 py-1 text-sm font-semibold rounded-full shadow-xs text-accent-contrast focus:ring-accent md:px-4 md:py-2 md:text-base bg-accent-base hover:bg-accent-hover"><?= lang('Common.go_back') ?></a> <a href="<?= previous_url() ?>" class="inline-flex items-center justify-center px-3 py-1 text-sm font-semibold rounded-full shadow-xs text-accent-contrast md:px-4 md:py-2 md:text-base bg-accent-base hover:bg-accent-hover"><?= lang('Common.go_back') ?></a>
</body> </body>
</html> </html>

View File

@ -28,7 +28,7 @@
<h2 class="font-mono font-semibold"><?= esc($title), esc($exception->getCode() ? ' #' . $exception->getCode() : '') ?></h2> <h2 class="font-mono font-semibold"><?= esc($title), esc($exception->getCode() ? ' #' . $exception->getCode() : '') ?></h2>
<p class="font-mono"><?= nl2br(esc($exception->getMessage())) ?><br/><span class="pl-4">at <span class="select-all bg-elevated"><?= nl2br(esc($exception->getFile())) ?>:<?= esc($exception->getLine()) ?></span></span></p> <p class="font-mono"><?= nl2br(esc($exception->getMessage())) ?><br/><span class="pl-4">at <span class="select-all bg-elevated"><?= nl2br(esc($exception->getFile())) ?>:<?= esc($exception->getLine()) ?></span></span></p>
<p id="error-stack-trace" class="hidden"><?= nl2br(esc($exception)) ?></p> <p id="error-stack-trace" class="hidden"><?= nl2br(esc($exception)) ?></p>
<clipboard-copy for="error-stack-trace" class="items-center self-end px-3 py-1 mt-2 font-semibold leading-8 transition-all rounded-full shadow group text-accent-contrast hover:bg-accent-hover bg-accent-base focus:ring-accent"> <clipboard-copy for="error-stack-trace" class="items-center self-end px-3 py-1 mt-2 font-semibold leading-8 transition-all rounded-full shadow group text-accent-contrast hover:bg-accent-hover bg-accent-base">
<span class="inline-flex items-center copy-base"><?= icon('file-copy-fill', [ <span class="inline-flex items-center copy-base"><?= icon('file-copy-fill', [
'class' => 'mr-2', 'class' => 'mr-2',
]) ?>Copy stack trace</span> ]) ?>Copy stack trace</span>
@ -41,11 +41,11 @@
<div class="flex flex-col justify-center w-full gap-6 py-12 border-t-2 md:flex-row border-subtle"> <div class="flex flex-col justify-center w-full gap-6 py-12 border-t-2 md:flex-row border-subtle">
<div class="w-full max-w-md mx-auto md:mx-0"> <div class="w-full max-w-md mx-auto md:mx-0">
<h2 class="text-xl font-semibold font-display">Found a bug?</h2> <h2 class="text-xl font-semibold font-display">Found a bug?</h2>
<p>You can help get it fixed by <a href="https://castopod.org/new-issue_bug" target="_blank" rel="noopener noreferrer" class="underline decoration-3 hover:no-underline focus:ring-accent decoration-accent">creating an issue on the Castopod issue tracker</a>. Please check that the issue does not already exist beforehand.</p> <p>You can help get it fixed by <a href="https://castopod.org/new-issue_bug" target="_blank" rel="noopener noreferrer" class="underline decoration-3 hover:no-underline decoration-accent">creating an issue on the Castopod issue tracker</a>. Please check that the issue does not already exist beforehand.</p>
</div> </div>
<div class="w-full max-w-md mx-auto md:mx-0"> <div class="w-full max-w-md mx-auto md:mx-0">
<h2 class="text-xl font-semibold font-display">Not sure what's happening?</h2> <h2 class="text-xl font-semibold font-display">Not sure what's happening?</h2>
<p>You can ask for help in the <a href="https://castopod.org/chat" target="_blank" rel="noopener noreferrer" class="underline decoration-2 hover:no-underline focus:ring-accent decoration-accent">Castopod community chat</a>!</p> <p>You can ask for help in the <a href="https://castopod.org/chat" target="_blank" rel="noopener noreferrer" class="underline decoration-2 hover:no-underline decoration-accent">Castopod community chat</a>!</p>
</div> </div>
</div> </div>
<?php else: ?> <?php else: ?>

View File

@ -41,6 +41,9 @@ class PluginController extends BaseController
$plugins = service('plugins'); $plugins = service('plugins');
$vendorPlugins = $plugins->getVendorPlugins($vendor); $vendorPlugins = $plugins->getVendorPlugins($vendor);
replace_breadcrumb_params([
$vendor => $vendor,
]);
return view('plugins/installed', [ return view('plugins/installed', [
'total' => count($vendorPlugins), 'total' => count($vendorPlugins),
'plugins' => $vendorPlugins, 'plugins' => $vendorPlugins,
@ -59,6 +62,10 @@ class PluginController extends BaseController
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
replace_breadcrumb_params([
$vendor => $vendor,
$package => $package,
]);
return view('plugins/view', [ return view('plugins/view', [
'plugin' => $plugin, 'plugin' => $plugin,
]); ]);
@ -76,6 +83,10 @@ class PluginController extends BaseController
} }
helper('form'); helper('form');
replace_breadcrumb_params([
$vendor => $vendor,
$package => $package,
]);
return view('plugins/settings_general', [ return view('plugins/settings_general', [
'plugin' => $plugin, 'plugin' => $plugin,
]); ]);
@ -122,7 +133,9 @@ class PluginController extends BaseController
helper('form'); helper('form');
replace_breadcrumb_params([ replace_breadcrumb_params([
0 => $podcast->handle, 0 => $podcast->handle,
$vendor => $vendor,
$package => $package,
]); ]);
return view('plugins/settings_podcast', [ return view('plugins/settings_podcast', [
'podcast' => $podcast, 'podcast' => $podcast,
@ -171,8 +184,10 @@ class PluginController extends BaseController
helper('form'); helper('form');
replace_breadcrumb_params([ replace_breadcrumb_params([
0 => $episode->podcast->handle, 0 => $episode->podcast->handle,
1 => $episode->title, 1 => $episode->title,
$vendor => $vendor,
$package => $package,
]); ]);
return view('plugins/settings_episode', [ return view('plugins/settings_episode', [
'podcast' => $episode->podcast, 'podcast' => $episode->podcast,

View File

@ -17,6 +17,8 @@ use League\CommonMark\MarkdownConverter;
use Modules\Plugins\ExternalImageProcessor; use Modules\Plugins\ExternalImageProcessor;
use Modules\Plugins\ExternalLinkProcessor; use Modules\Plugins\ExternalLinkProcessor;
use Modules\Plugins\Manifest\Manifest; use Modules\Plugins\Manifest\Manifest;
use Modules\Plugins\Manifest\Person;
use Modules\Plugins\Manifest\Repository;
use Modules\Plugins\Manifest\Settings; use Modules\Plugins\Manifest\Settings;
use Modules\Plugins\Manifest\SettingsField; use Modules\Plugins\Manifest\SettingsField;
use RuntimeException; use RuntimeException;
@ -35,7 +37,7 @@ abstract class BasePlugin implements PluginInterface
protected Manifest $manifest; protected Manifest $manifest;
protected string $readmeHTML; protected ?string $readmeHTML;
public function __construct( public function __construct(
protected string $vendor, protected string $vendor,
@ -121,6 +123,27 @@ abstract class BasePlugin implements PluginInterface
return $this->manifest->homepage; return $this->manifest->homepage;
} }
final public function getRepository(): ?Repository
{
return $this->manifest->repository;
}
/**
* @return list<string>
*/
final public function getKeywords(): array
{
return $this->manifest->keywords;
}
/**
* @return Person[]
*/
final public function getAuthors(): array
{
return $this->manifest->authors;
}
final public function getIconSrc(): string final public function getIconSrc(): string
{ {
return $this->iconSrc; return $this->iconSrc;
@ -194,11 +217,16 @@ abstract class BasePlugin implements PluginInterface
return $description; return $description;
} }
final public function getReadmeHTML(): string final public function getReadmeHTML(): ?string
{ {
return $this->readmeHTML; return $this->readmeHTML;
} }
final public function getLicense(): string
{
return $this->manifest->license ?? 'UNLICENSED';
}
final protected function getOption(string $option): mixed final protected function getOption(string $option): mixed
{ {
return get_plugin_option($this->key, $option); return get_plugin_option($this->key, $option);
@ -238,7 +266,7 @@ abstract class BasePlugin implements PluginInterface
$environment = new Environment([ $environment = new Environment([
'html_input' => 'escape', 'html_input' => 'escape',
'allow_unsafe_links' => false, 'allow_unsafe_links' => false,
'host' => 'hello', 'host' => (new URI(base_url()))->getHost(),
]); ]);
$environment->addExtension(new CommonMarkCoreExtension()); $environment->addExtension(new CommonMarkCoreExtension());
@ -247,11 +275,15 @@ abstract class BasePlugin implements PluginInterface
$environment->addEventListener( $environment->addEventListener(
DocumentParsedEvent::class, DocumentParsedEvent::class,
[new ExternalLinkProcessor($environment), 'onDocumentParsed'] static function (DocumentParsedEvent $event): void {
(new ExternalLinkProcessor())->onDocumentParsed($event);
}
); );
$environment->addEventListener( $environment->addEventListener(
DocumentParsedEvent::class, DocumentParsedEvent::class,
[new ExternalImageProcessor($environment), 'onDocumentParsed'] static function (DocumentParsedEvent $event): void {
(new ExternalImageProcessor())->onDocumentParsed($event);
}
); );
$converter = new MarkdownConverter($environment); $converter = new MarkdownConverter($environment);

View File

@ -35,6 +35,8 @@ class Plugins
protected static int $installedCount = 0; protected static int $installedCount = 0;
protected static int $activeCount = 0;
public function __construct() public function __construct()
{ {
helper('plugins'); helper('plugins');
@ -63,6 +65,21 @@ class Plugins
return array_slice(static::$plugins, (($page - 1) * $perPage), $perPage); return array_slice(static::$plugins, (($page - 1) * $perPage), $perPage);
} }
/**
* @return array<BasePlugin>
*/
public function getActivePlugins(): array
{
$activePlugins = [];
foreach (static::$plugins as $plugin) {
if ($plugin->isActive()) {
$activePlugins[] = $plugin;
}
}
return $activePlugins;
}
/** /**
* @return array<BasePlugin> * @return array<BasePlugin>
*/ */
@ -177,6 +194,11 @@ class Plugins
return static::$installedCount; return static::$installedCount;
} }
public function getActiveCount(): int
{
return static::$activeCount;
}
public function uninstall(BasePlugin $plugin): bool public function uninstall(BasePlugin $plugin): bool
{ {
// remove all settings data // remove all settings data
@ -235,6 +257,10 @@ class Plugins
static::$plugins[] = $plugin; static::$plugins[] = $plugin;
static::$pluginsByVendor[$vendor][] = $plugin; static::$pluginsByVendor[$vendor][] = $plugin;
++static::$installedCount; ++static::$installedCount;
if ($plugin->isActive()) {
++static::$activeCount;
}
} }
} }

View File

@ -5,19 +5,11 @@ declare(strict_types=1);
namespace Modules\Plugins; namespace Modules\Plugins;
use CodeIgniter\HTTP\URI; use CodeIgniter\HTTP\URI;
use League\CommonMark\Environment\EnvironmentInterface;
use League\CommonMark\Event\DocumentParsedEvent; use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image; use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
class ExternalImageProcessor class ExternalImageProcessor
{ {
private EnvironmentInterface $environment;
public function __construct(EnvironmentInterface $environment)
{
$this->environment = $environment;
}
public function onDocumentParsed(DocumentParsedEvent $event): void public function onDocumentParsed(DocumentParsedEvent $event): void
{ {
$document = $event->getDocument(); $document = $event->getDocument();
@ -47,7 +39,6 @@ class ExternalImageProcessor
$host = parse_url($url, PHP_URL_HOST); $host = parse_url($url, PHP_URL_HOST);
// TODO: load from environment's config // TODO: load from environment's config
// return $host != $this->environment->getConfiguration()->get('host');
return $host !== (new URI(base_url()))->getHost(); return $host !== (new URI(base_url()))->getHost();
} }
} }

View File

@ -5,19 +5,11 @@ declare(strict_types=1);
namespace Modules\Plugins; namespace Modules\Plugins;
use CodeIgniter\HTTP\URI; use CodeIgniter\HTTP\URI;
use League\CommonMark\Environment\EnvironmentInterface;
use League\CommonMark\Event\DocumentParsedEvent; use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link; use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
class ExternalLinkProcessor class ExternalLinkProcessor
{ {
private EnvironmentInterface $environment;
public function __construct(EnvironmentInterface $environment)
{
$this->environment = $environment;
}
public function onDocumentParsed(DocumentParsedEvent $event): void public function onDocumentParsed(DocumentParsedEvent $event): void
{ {
$document = $event->getDocument(); $document = $event->getDocument();
@ -48,7 +40,6 @@ class ExternalLinkProcessor
$host = parse_url($url, PHP_URL_HOST); $host = parse_url($url, PHP_URL_HOST);
// TODO: load from environment's config // TODO: load from environment's config
// return $host != $this->environment->getConfiguration()->get('host');
return $host !== (new URI(base_url()))->getHost(); return $host !== (new URI(base_url()))->getHost();
} }
} }

View File

@ -9,11 +9,19 @@ declare(strict_types=1);
*/ */
return [ return [
'installed' => 'Installed plugins ({count})', 'installed' => 'Plugins installed',
'about' => 'About',
'website' => 'Website', 'website' => 'Website',
'settings' => '{pluginName} settings', 'repository' => 'Code repository',
'authors' => 'Authors',
'author_email' => 'Email {authorName}',
'author_homepage' => '{authorName} homepage',
'settings' => 'Settings',
'view' => 'View',
'activate' => 'Activate', 'activate' => 'Activate',
'deactivate' => 'Deactivate', 'deactivate' => 'Deactivate',
'active' => 'Active',
'inactive' => 'Inactive',
'uninstall' => 'Uninstall', 'uninstall' => 'Uninstall',
'keywords' => [ 'keywords' => [
'podcasting20' => 'Podcasting 2.0', 'podcasting20' => 'Podcasting 2.0',

View File

@ -10,14 +10,15 @@ use CodeIgniter\HTTP\URI;
* @property string $name * @property string $name
* @property string $version * @property string $version
* @property ?string $description * @property ?string $description
* @property ?Author $author * @property Person[] $authors
* @property Author[] $authors * @property Person[] $contributors
* @property ?URI $homepage * @property ?URI $homepage
* @property ?string $license * @property ?string $license
* @property bool $private * @property bool $private
* @property list<string> $keywords * @property list<string> $keywords
* @property list<string> $hooks * @property list<string> $hooks
* @property ?Settings $settings * @property ?Settings $settings
* @property ?Repository $repository
*/ */
class Manifest extends ManifestObject class Manifest extends ManifestObject
{ {
@ -25,27 +26,27 @@ class Manifest extends ManifestObject
* @var array<string,string> * @var array<string,string>
*/ */
protected const VALIDATION_RULES = [ protected const VALIDATION_RULES = [
'name' => 'required|max_length[32]', 'name' => 'required|max_length[128]',
'version' => 'required|regex_match[/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/]', 'version' => 'required|regex_match[/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/]',
'description' => 'permit_empty|max_length[128]', 'description' => 'permit_empty|max_length[256]',
'author' => 'permit_empty',
'authors' => 'permit_empty|is_list', 'authors' => 'permit_empty|is_list',
'homepage' => 'permit_empty|valid_url_strict', 'homepage' => 'permit_empty|valid_url_strict',
'license' => 'permit_empty|string', 'license' => 'permit_empty|string',
'private' => 'permit_empty|is_boolean', 'private' => 'permit_empty|is_boolean',
'keywords.*' => 'permit_empty', 'keywords.*' => 'permit_empty',
'hooks.*' => 'permit_empty|in_list[channelTag,itemTag,siteHead]', 'hooks.*' => 'permit_empty|in_list[channelTag,itemTag,siteHead]',
'settings' => 'permit_empty', 'settings' => 'permit_empty|is_list',
'repository' => 'permit_empty|is_list',
]; ];
/** /**
* @var array<string,array{string}|string> * @var array<string,array{string}|string>
*/ */
protected const CASTS = [ protected const CASTS = [
'author' => Author::class, 'authors' => [Person::class],
'authors' => [Author::class], 'homepage' => URI::class,
'homepage' => URI::class, 'settings' => Settings::class,
'settings' => Settings::class, 'repository' => Repository::class,
]; ];
protected string $name; protected string $name;
@ -54,10 +55,8 @@ class Manifest extends ManifestObject
protected ?string $description = null; protected ?string $description = null;
protected ?Author $author = null;
/** /**
* @var Author[] * @var Person[]
*/ */
protected array $authors = []; protected array $authors = [];
@ -78,4 +77,6 @@ class Manifest extends ManifestObject
protected array $hooks = []; protected array $hooks = [];
protected ?Settings $settings = null; protected ?Settings $settings = null;
protected ?Repository $repository = null;
} }

View File

@ -32,7 +32,7 @@ abstract class ManifestObject
public function __get(string $name): mixed public function __get(string $name): mixed
{ {
if (isset($this->{$name})) { if (property_exists($this, $name)) {
return $this->{$name}; return $this->{$name};
} }

View File

@ -12,7 +12,7 @@ use Exception;
* @property ?string $email * @property ?string $email
* @property ?URI $url * @property ?URI $url
*/ */
class Author extends ManifestObject class Person extends ManifestObject
{ {
protected const VALIDATION_RULES = [ protected const VALIDATION_RULES = [
'name' => 'required', 'name' => 'required',

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Modules\Plugins\Manifest;
use CodeIgniter\HTTP\URI;
/**
* @property string $type
* @property ?URI $url
* @property ?string $directory
*/
class Repository extends ManifestObject
{
protected const VALIDATION_RULES = [
'type' => 'required|in_list[git]',
'url' => 'required|valid_url_strict',
'directory' => 'permit_empty',
];
/**
* @var array<string,array{string}|string>
*/
protected const CASTS = [
'url' => URI::class,
];
protected string $type;
protected URI $url;
protected ?string $directory = null;
}

View File

@ -29,9 +29,9 @@ class SettingsField extends ManifestObject
protected string $label; protected string $label;
protected ?string $hint = ''; protected string $hint = '';
protected ?string $helper = ''; protected string $helper = '';
protected bool $optional = false; protected bool $optional = false;
} }

View File

@ -21,9 +21,6 @@
"description": "This helps people discover your plugin as it's listed in repositories", "description": "This helps people discover your plugin as it's listed in repositories",
"type": "string" "type": "string"
}, },
"author": {
"$ref": "#/$defs/person"
},
"authors": { "authors": {
"type": "array", "type": "array",
"items": { "items": {

View File

@ -139,6 +139,9 @@ module.exports = {
textDecoration: "none", textDecoration: "none",
}, },
}, },
input: {
margin: 0,
},
}, },
}, },
sm: { sm: {

View File

@ -36,15 +36,15 @@ $isEpisodeArea = isset($podcast) && isset($episode);
<div class="flex flex-col justify-end w-full -mt-4 sticky-header-inner bg-elevated"> <div class="flex flex-col justify-end w-full -mt-4 sticky-header-inner bg-elevated">
<?= render_breadcrumb('text-xs items-center flex') ?> <?= render_breadcrumb('text-xs items-center flex') ?>
<div class="flex justify-between py-1"> <div class="flex justify-between py-1">
<div class="flex flex-wrap items-center truncate"> <div class="flex flex-wrap items-center truncate gap-x-2">
<?php if (($isEpisodeArea && $episode->is_premium) || ($isPodcastArea && $podcast->is_premium)): ?> <?php if (($isEpisodeArea && $episode->is_premium) || ($isPodcastArea && $podcast->is_premium)): ?>
<div class="inline-flex items-center"> <div class="inline-flex items-center">
<?php // @icon('exchange-dollar-fill')?> <?php // @icon('exchange-dollar-fill')?>
<IconButton uri="<?= route_to('subscription-list', $podcast->id) ?>" glyph="exchange-dollar-fill" variant="secondary" size="large" class="p-0 mr-2 border-0"><?= ($isEpisodeArea && $episode->is_premium) ? lang('PremiumPodcasts.episode_is_premium') : lang('PremiumPodcasts.podcast_is_premium') ?></IconButton> <x-IconButton uri="<?= route_to('subscription-list', $podcast->id) ?>" glyph="exchange-dollar-fill" variant="secondary" size="large" class="p-0 mr-2 border-0"><?= ($isEpisodeArea && $episode->is_premium) ? lang('PremiumPodcasts.episode_is_premium') : lang('PremiumPodcasts.podcast_is_premium') ?></x-IconButton>
<Heading tagName="h1" size="large" class="truncate"><?= $this->renderSection('pageTitle') ?></Heading> <x-Heading tagName="h1" size="large" class="truncate"><?= $this->renderSection('pageTitle') ?></x-Heading>
</div> </div>
<?php else: ?> <?php else: ?>
<Heading tagName="h1" size="large" class="truncate"><?= $this->renderSection('pageTitle') ?></Heading> <x-Heading tagName="h1" size="large" class="truncate"><?= $this->renderSection('pageTitle') ?></x-Heading>
<?php endif; ?> <?php endif; ?>
<?= $this->renderSection('headerLeft') ?> <?= $this->renderSection('headerLeft') ?>
</div> </div>

View File

@ -1,33 +1,33 @@
<?php declare(strict_types=1); <?php declare(strict_types=1);
if (session()->has('message')): ?> if (session()->has('message')): ?>
<Alert variant="success" class="mb-4"><?= esc(session('message')) ?></Alert> <x-Alert variant="success" class="mb-4"><?= esc(session('message')) ?></x-Alert>
<?php endif; ?> <?php endif; ?>
<?php if (session()->has('error')): ?> <?php if (session()->has('error')): ?>
<Alert variant="danger" class="mb-4"><?= esc(session('error')) ?></Alert> <x-Alert variant="danger" class="mb-4"><?= esc(session('error')) ?></x-Alert>
<?php endif; ?> <?php endif; ?>
<?php if (session()->has('errors')): ?> <?php if (session()->has('errors')): ?>
<Alert variant="danger" class="mb-4"> <x-Alert variant="danger" class="mb-4">
<ul> <ul>
<?php foreach (session('errors') as $error): ?> <?php foreach (session('errors') as $error): ?>
<li><?= esc($error) ?></li> <li><?= esc($error) ?></li>
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>
</Alert> </x-Alert>
<?php endif; ?> <?php endif; ?>
<?php if (session()->has('warning')): ?> <?php if (session()->has('warning')): ?>
<Alert variant="warning" class="mb-4"><?= esc(session('warning')) ?></Alert> <x-Alert variant="warning" class="mb-4"><?= esc(session('warning')) ?></x-Alert>
<?php endif; ?> <?php endif; ?>
<?php if (session()->has('warnings')): ?> <?php if (session()->has('warnings')): ?>
<Alert variant="warning" class="mb-4"> <x-Alert variant="warning" class="mb-4">
<ul> <ul>
<?php foreach (session('warnings') as $warning): ?> <?php foreach (session('warnings') as $warning): ?>
<li><?= esc($warning) ?></li> <li><?= esc($warning) ?></li>
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>
</Alert> </x-Alert>
<?php endif; ?> <?php endif; ?>

View File

@ -15,7 +15,7 @@ $isEpisodeArea = isset($podcast) && isset($episode);
<?php endif; ?> <?php endif; ?>
<footer class="px-2 py-2 mx-auto text-xs text-right"> <footer class="px-2 py-2 mx-auto text-xs text-right">
<?= lang('Common.powered_by', [ <?= lang('Common.powered_by', [
'castopod' => '<a class="inline-flex font-semibold hover:underline focus:ring-accent" href="https://castopod.org/" target="_blank" rel="noreferrer noopener">Castopod' . icon('social:castopod', [ 'castopod' => '<a class="inline-flex font-semibold hover:underline" href="https://castopod.org/" target="_blank" rel="noreferrer noopener">Castopod' . icon('social:castopod', [
'class' => 'ml-1 text-lg', 'class' => 'ml-1 text-lg',
]) . '</a> ' . ]) . '</a> ' .
CP_VERSION, CP_VERSION,

View File

@ -5,18 +5,18 @@ $userPodcasts = get_podcasts_user_can_interact_with(auth()->user()); ?>
<header class="sticky top-0 z-[60] flex items-center h-10 text-white border-b col-span-full bg-navigation border-navigation"> <header class="sticky top-0 z-[60] flex items-center h-10 text-white border-b col-span-full bg-navigation border-navigation">
<button type="button" <button type="button"
data-sidebar-toggler="toggler" data-sidebar-toggler="toggler"
class="h-full pr-1 text-xl md:hidden focus:ring-accent focus:ring-inset" aria-label="<?= lang('Navigation.toggle_sidebar') ?>"><?= icon('menu-2-fill') ?></button> class="h-full pr-1 text-xl md:hidden" aria-label="<?= lang('Navigation.toggle_sidebar') ?>"><?= icon('menu-2-fill') ?></button>
<div class="inline-flex items-center h-full"> <div class="inline-flex items-center h-full">
<a href="<?= route_to( <a href="<?= route_to(
'admin', 'admin',
) ?>" class="inline-flex items-center h-full px-2 border-r border-navigation focus:ring-inset focus:ring-accent"> ) ?>" class="inline-flex items-center h-full px-2 border-r border-navigation">
<?= (isset($podcast) ? icon('arrow-left-line', [ <?= (isset($podcast) ? icon('arrow-left-line', [
'class' => 'mr-2', 'class' => 'mr-2',
]) : '') . svg('castopod-logo-base', 'h-6') ?> ]) : '') . svg('castopod-logo-base', 'h-6') ?>
</a> </a>
<a href="<?= route_to( <a href="<?= route_to(
'home', 'home',
) ?>" class="inline-flex items-center h-full px-2 text-sm font-semibold sm:px-6 hover:underline focus:ring-inset focus:ring-accent" title="<?= lang('Navigation.go_to_website') ?>"> ) ?>" class="inline-flex items-center h-full px-2 text-sm font-semibold sm:px-6 hover:underline" title="<?= lang('Navigation.go_to_website') ?>">
<span class="hidden sm:block"><?= lang('Navigation.go_to_website') ?></span> <span class="hidden sm:block"><?= lang('Navigation.go_to_website') ?></span>
<?= icon('external-link-fill', [ <?= icon('external-link-fill', [
'class' => 'sm:ml-1 text-xl sm:text-base sm:opacity-60', 'class' => 'sm:ml-1 text-xl sm:text-base sm:opacity-60',
@ -24,7 +24,7 @@ $userPodcasts = get_podcasts_user_can_interact_with(auth()->user()); ?>
</a> </a>
</div> </div>
<div class="inline-flex items-center h-full ml-auto"> <div class="inline-flex items-center h-full ml-auto">
<button type="button" class="relative h-full px-2 focus:ring-accent focus:ring-inset" id="notifications-dropdown" data-dropdown="button" data-dropdown-target="notifications-dropdown-menu" aria-haspopup="true" aria-expanded="false" title="<?= lang('Notifications.title') ?>" data-tooltip="bottom"> <button type="button" class="relative h-full px-2" id="notifications-dropdown" data-dropdown="button" data-dropdown-target="notifications-dropdown-menu" aria-haspopup="true" aria-expanded="false" title="<?= lang('Notifications.title') ?>" data-tooltip="bottom">
<?= icon('notification-2-fill', [ <?= icon('notification-2-fill', [
'class' => 'text-2xl opacity-80', 'class' => 'text-2xl opacity-80',
]) ?> ]) ?>
@ -74,11 +74,11 @@ if ($userPodcasts !== []) {
]; ];
} }
?> ?>
<DropdownMenu id="notifications-dropdown-menu" labelledby="notifications-dropdown" items="<?= esc(json_encode($items)) ?>" placement="bottom-end"/> <x-DropdownMenu id="notifications-dropdown-menu" labelledby="notifications-dropdown" items="<?= esc(json_encode($items)) ?>" placement="bottom-end"/>
<button <button
type="button" type="button"
class="inline-flex items-center h-full px-3 text-sm font-semibold focus:ring-inset focus:ring-accent gap-x-2" class="inline-flex items-center h-full px-3 text-sm font-semibold gap-x-2"
id="my-account-dropdown" id="my-account-dropdown"
data-dropdown="button" data-dropdown="button"
data-dropdown-target="my-account-dropdown-menu" data-dropdown-target="my-account-dropdown-menu"
@ -154,5 +154,5 @@ if ($userPodcasts !== []) {
], $menuItems); ], $menuItems);
} }
?> ?>
<DropdownMenu id="my-account-dropdown-menu" labelledby="my-account-dropdown" items="<?= esc(json_encode($menuItems)) ?>" /> <x-DropdownMenu id="my-account-dropdown-menu" labelledby="my-account-dropdown" items="<?= esc(json_encode($menuItems)) ?>" />
</header> </header>

View File

@ -15,18 +15,18 @@
} }
?> ?>
<details <?= $isSectionActive ? 'open="open"' : '' ?> class="<?= $isSectionActive ? 'bg-navigation-active' : '' ?> [&[open]>summary::after]:rotate-90"> <details <?= $isSectionActive ? 'open="open"' : '' ?> class="<?= $isSectionActive ? 'bg-navigation-active' : '' ?> [&[open]>summary::after]:rotate-90">
<summary class="inline-flex items-center w-full px-4 py-2 font-semibold focus:ring-accent focus:ring-inset after:w-5 after:h-5 after:transition-transform after:content-chevronRightIcon after:ml-2 after:opacity-60 after:text-white"> <summary class="inline-flex items-center w-full h-12 px-4 py-2 font-semibold after:w-5 after:h-5 after:transition-transform after:content-chevronRightIcon after:ml-2 after:opacity-60 after:text-white">
<div class="inline-flex items-center mr-auto"> <div class="inline-flex items-center mr-auto">
<?= icon($data['icon'], [ <?= icon($data['icon'], [
'class' => 'opacity-60 text-2xl mr-4', 'class' => 'opacity-60 text-2xl mr-4',
]) ?> ]) ?>
<?= lang($langKey . '.' . $section) ?> <?= lang($langKey . '.' . $section) ?>
<?php if (array_key_exists('count', $data)): ?> <?php if (array_key_exists('count', $data)): ?>
<a href="<?= route_to($data['count-route'], $podcastId ?? null, $episodeId ?? null) ?>" class="px-2 ml-2 text-xs font-normal rounded-full focus:ring-accent <?= $isSectionActive ? 'bg-navigation' : 'bg-navigation-active' ?>"><?= $data['count'] ?></a> <a href="<?= route_to($data['count-route'], $podcastId ?? null, $episodeId ?? null) ?>" class="px-2 ml-2 text-xs font-normal rounded-full <?= $isSectionActive ? 'bg-navigation' : 'bg-navigation-active' ?>"><?= $data['count'] ?></a>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if(array_key_exists('add-cta', $data)): ?> <?php if(array_key_exists('add-cta', $data)): ?>
<a href="<?= route_to($data['add-cta'], $podcastId ?? null, $episodeId ?? null) ?>" class="p-2 rounded-full shadow bg-accent-base focus:ring-accent" title="<?= lang($langKey . '.' . $data['add-cta']) ?>" data-tooltip="bottom"> <a href="<?= route_to($data['add-cta'], $podcastId ?? null, $episodeId ?? null) ?>" class="p-2 rounded-full shadow bg-accent-base" title="<?= lang($langKey . '.' . $data['add-cta']) ?>" data-tooltip="bottom">
<?= icon('add-fill') ?> <?= icon('add-fill') ?>
</a> </a>
<?php endif; ?> <?php endif; ?>
@ -34,7 +34,8 @@
<ul class="flex flex-col pb-4"> <ul class="flex flex-col pb-4">
<?php foreach ($data['items'] as $key => $item): <?php foreach ($data['items'] as $key => $item):
$isActive = $item === $activeItem; $isActive = $item === $activeItem;
$label = array_key_exists('items-labels', $data) ? $data['items-labels'][$key] : lang($langKey . '.' . $item);
$label = (array_key_exists('items-labels', $data) && array_key_exists($item, $data['items-labels'])) ? $data['items-labels'][$item] : lang($langKey . '.' . $item);
$href = str_starts_with($item, '/') ? $item : route_to($item, $podcastId ?? null, $episodeId ?? null); $href = str_starts_with($item, '/') ? $item : route_to($item, $podcastId ?? null, $episodeId ?? null);
$isAllowed = true; $isAllowed = true;
@ -48,11 +49,11 @@
?> ?>
<li class="inline-flex"> <li class="inline-flex">
<?php if ($isAllowed): ?> <?php if ($isAllowed): ?>
<a class="relative w-full py-3 pl-14 pr-2 text-sm hover:opacity-100 before:content-chevronRightIcon before:absolute before:-ml-5 before:opacity-0 before:w-5 before:h-5 hover:bg-navigation-active focus:ring-inset focus:ring-accent<?= $isActive <a class="relative w-full py-3 pl-14 pr-2 text-sm hover:opacity-100 before:content-chevronRightIcon before:absolute before:-ml-5 before:opacity-0 before:w-5 before:h-5 hover:bg-navigation-active<?= $isActive
? ' before:opacity-100 font-semibold inline-flex items-center' ? ' before:opacity-100 font-semibold inline-flex items-center'
: ' hover:before:opacity-60 focus:before:opacity-60' ?>" href="<?= $href ?>"><?= $label ?></a> : ' hover:before:opacity-60 focus:before:opacity-60' ?>" href="<?= $href ?>"><?= $label ?></a>
<?php else: ?> <?php else: ?>
<span data-tooltip="right" title="<?= lang('Navigation.not-authorized') ?>" class="relative w-full py-3 pr-2 text-sm cursor-not-allowed before:inset-y-0 before:my-auto pl-14 hover:opacity-100 before:absolute before:content-prohibitedIcon before:-ml-5 before:opacity-60 before:w-4 before:h-4 hover:bg-navigation-active focus:ring-inset focus:ring-accent"><?= $label ?></span> <span data-tooltip="right" title="<?= lang('Navigation.not-authorized') ?>" class="relative w-full py-3 pr-2 text-sm cursor-not-allowed before:inset-y-0 before:my-auto pl-14 hover:opacity-100 before:absolute before:content-prohibitedIcon before:-ml-5 before:opacity-60 before:w-4 before:h-4 hover:bg-navigation-active"><?= $label ?></span>
<?php endif; ?> <?php endif; ?>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>

View File

@ -22,12 +22,15 @@ $navigation = [
'count-route' => 'podcast-list', 'count-route' => 'podcast-list',
], ],
'plugins' => [ 'plugins' => [
'icon' => 'puzzle-fill', // @icon('puzzle-fill') 'icon' => 'puzzle-fill', // @icon('puzzle-fill')
'items' => ['plugins-installed'], 'items' => ['plugins-installed'],
'items-labels' => [
'plugins-installed' => lang('Navigation.plugins-installed') . ' (' . service('plugins')->getInstalledCount() . ')',
],
'items-permissions' => [ 'items-permissions' => [
'plugins-installed' => 'plugins.manage', 'plugins-installed' => 'plugins.manage',
], ],
'count' => service('plugins')->getInstalledCount(), 'count' => service('plugins')->getActiveCount(),
'count-route' => 'plugins-installed', 'count-route' => 'plugins-installed',
], ],
'persons' => [ 'persons' => [
@ -82,12 +85,20 @@ $navigation = [
], ],
]; ];
foreach (plugins()->getActivePlugins() as $plugin) {
$route = route_to('plugins-view', $plugin->getKey());
$navigation['plugins']['items'][] = $route;
$navigation['plugins']['items-labels'][$route] = $plugin->getName();
$navigation['plugins']['items-permissions'][$route] = 'plugins.manage';
}
if (auth()->user()->can('podcasts.view')) { if (auth()->user()->can('podcasts.view')) {
$navigation['podcasts']['count'] = (new PodcastModel())->countAllResults(); $navigation['podcasts']['count'] = (new PodcastModel())->countAllResults();
} else { } else {
$navigation['podcasts']['count'] = count(get_user_podcasts(auth()->user())); $navigation['podcasts']['count'] = count(get_user_podcasts(auth()->user()));
} ?> } ?>
<?= view('_partials/_nav_menu', [ <?= view('_partials/_nav_menu', [
'navigation' => $navigation, 'navigation' => $navigation,
'langKey' => 'Navigation', 'langKey' => 'Navigation',

View File

@ -14,24 +14,24 @@
<form method="POST" action="<?= route_to('contributor-add', $podcast->id) ?>" class="flex flex-col max-w-sm gap-y-4"> <form method="POST" action="<?= route_to('contributor-add', $podcast->id) ?>" class="flex flex-col max-w-sm gap-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Field <x-Forms.Field
as="Select" as="Select"
name="user" name="user"
label="<?= esc(lang('Contributor.form.user')) ?>" label="<?= esc(lang('Contributor.form.user')) ?>"
options="<?= esc(json_encode($contributorOptions)) ?>" options="<?= esc(json_encode($contributorOptions)) ?>"
placeholder="<?= lang('Contributor.form.user_placeholder') ?>" placeholder="<?= lang('Contributor.form.user_placeholder') ?>"
required="true" /> isRequired="true" />
<Forms.Field <x-Forms.Field
as="Select" as="Select"
name="role" name="role"
label="<?= esc(lang('Contributor.form.role')) ?>" label="<?= esc(lang('Contributor.form.role')) ?>"
options="<?= esc(json_encode($roleOptions)) ?>" options="<?= esc(json_encode($roleOptions)) ?>"
placeholder="<?= lang('Contributor.form.role_placeholder') ?>" placeholder="<?= lang('Contributor.form.role_placeholder') ?>"
selected="<?= setting('AuthGroups.defaultPodcastGroup') ?>" selected="<?= setting('AuthGroups.defaultPodcastGroup') ?>"
required="true" /> isRequired="true" />
<Button type="submit" class="self-end" variant="primary"><?= lang('Contributor.form.submit_add') ?></Button> <x-Button type="submit" class="self-end" variant="primary"><?= lang('Contributor.form.submit_add') ?></x-Button>
</form> </form>

View File

@ -17,19 +17,19 @@
<form action="<?= route_to('contributor-delete', $podcast->id, $contributor->id) ?>" method="POST" class="flex flex-col w-full max-w-xl mx-auto"> <form action="<?= route_to('contributor-delete', $podcast->id, $contributor->id) ?>" method="POST" class="flex flex-col w-full max-w-xl mx-auto">
<?= csrf_field() ?> <?= csrf_field() ?>
<Alert variant="danger" class="font-semibold"><?= lang('Contributor.delete_form.disclaimer', [ <x-Alert variant="danger" class="font-semibold"><?= lang('Contributor.delete_form.disclaimer', [
'contributor' => $contributor->username, 'contributor' => $contributor->username,
'podcastTitle' => $podcast->title, 'podcastTitle' => $podcast->title,
]) ?></Alert> ]) ?></x-Alert>
<Forms.Checkbox class="mt-2" name="understand" required="true" isChecked="false"><?= lang('Contributor.delete_form.understand', [ <x-Forms.Checkbox class="mt-2" name="understand" isRequired="true" isChecked="false"><?= lang('Contributor.delete_form.understand', [
'contributor' => $contributor->username, 'contributor' => $contributor->username,
'podcastTitle' => $podcast->title, 'podcastTitle' => $podcast->title,
]) ?></Forms.Checkbox> ]) ?></x-Forms.Checkbox>
<div class="self-end mt-4"> <div class="self-end mt-4">
<Button uri="<?= route_to('contributor-view', $podcast->id, $contributor->id) ?>"><?= lang('Common.cancel') ?></Button> <x-Button uri="<?= route_to('contributor-view', $podcast->id, $contributor->id) ?>"><?= lang('Common.cancel') ?></x-Button>
<Button type="submit" variant="danger"><?= lang('Contributor.delete_form.submit') ?></Button> <x-Button type="submit" variant="danger"><?= lang('Contributor.delete_form.submit') ?></x-Button>
</div> </div>
</form> </form>

View File

@ -14,16 +14,16 @@
<form method="POST" action="<?= route_to('contributor-edit', $podcast->id, $contributor->id) ?>" class="flex flex-col max-w-sm gap-y-4"> <form method="POST" action="<?= route_to('contributor-edit', $podcast->id, $contributor->id) ?>" class="flex flex-col max-w-sm gap-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Field <x-Forms.Field
as="Select" as="Select"
name="role" name="role"
label="<?= esc(lang('Contributor.form.role')) ?>" label="<?= esc(lang('Contributor.form.role')) ?>"
options="<?= esc(json_encode($roleOptions)) ?>" options="<?= esc(json_encode($roleOptions)) ?>"
selected="<?= $contributorGroup ?>" selected="<?= $contributorGroup ?>"
placeholder="<?= lang('Contributor.form.role_placeholder') ?>" placeholder="<?= lang('Contributor.form.role_placeholder') ?>"
required="true" /> isRequired="true" />
<Button variant="primary" type="submit" class="self-end"><?= lang('Contributor.form.submit_edit') ?></Button> <x-Button variant="primary" type="submit" class="self-end"><?= lang('Contributor.form.submit_edit') ?></x-Button>
</form> </form>

View File

@ -10,7 +10,7 @@
<?= $this->section('headerRight') ?> <?= $this->section('headerRight') ?>
<?php // @icon('add-fill')?> <?php // @icon('add-fill')?>
<Button uri="<?= route_to('contributor-add', $podcast->id) ?>" variant="primary" iconLeft="add-fill"><?= lang('Contributor.add') ?></Button> <x-Button uri="<?= route_to('contributor-add', $podcast->id) ?>" variant="primary" iconLeft="add-fill"><?= lang('Contributor.add') ?></x-Button>
<?= $this->endSection() ?> <?= $this->endSection() ?>
@ -30,7 +30,7 @@
$role = get_group_info(get_podcast_group($contributor, $podcast->id), $podcast->id)['title']; $role = get_group_info(get_podcast_group($contributor, $podcast->id), $podcast->id)['title'];
if ($podcast->created_by === $contributor->id) { if ($podcast->created_by === $contributor->id) {
$role = '<div class="inline-flex items-center"><span class="mr-2 focus:ring-accent" tabindex="0" data-tooltip="bottom" title="' . lang('Auth.podcast_groups.owner.title') . '">' . icon('shield-user-fill') . '</span>' . $role . '</div>'; $role = '<div class="inline-flex items-center"><span class="mr-2" tabindex="0" data-tooltip="bottom" title="' . lang('Auth.podcast_groups.owner.title') . '">' . icon('shield-user-fill') . '</span>' . $role . '</div>';
} }
return $role; return $role;
@ -41,8 +41,8 @@
'cell' => function ($contributor, $podcast) { 'cell' => function ($contributor, $podcast) {
// @icon('pencil-fill') // @icon('pencil-fill')
// @icon('delete-bin-fill') // @icon('delete-bin-fill')
return '<Button uri="' . route_to('contributor-edit', $podcast->id, $contributor->id) . '" variant="secondary" iconLeft="pencil-fill" size="small">' . lang('Contributor.edit') . '</Button>' . return '<x-Button uri="' . route_to('contributor-edit', $podcast->id, $contributor->id) . '" variant="secondary" iconLeft="pencil-fill" size="small">' . lang('Contributor.edit') . '</x-Button>' .
'<Button uri="' . route_to('contributor-remove', $podcast->id, $contributor->id) . '" variant="danger" iconLeft="delete-bin-fill" size="small">' . lang('Contributor.remove') . '</Button>'; '<x-Button uri="' . route_to('contributor-remove', $podcast->id, $contributor->id) . '" variant="danger" iconLeft="delete-bin-fill" size="small">' . lang('Contributor.remove') . '</x-Button>';
}, },
], ],
], ],

View File

@ -13,27 +13,27 @@
<div class="flex flex-col items-stretch gap-4 lg:flex-row"> <div class="flex flex-col items-stretch gap-4 lg:flex-row">
<?php // @icon('mic-fill')?> <?php // @icon('mic-fill')?>
<DashboardCard href="<?= $onlyPodcastId === null ? route_to('podcast-list') : route_to('podcast-view', $onlyPodcastId) ?>" glyph="mic-fill" title="<?= lang('Dashboard.podcasts.title') ?>" subtitle="<?= $podcastsData['last_published_at'] ? esc(lang('Dashboard.podcasts.last_published', [ <x-DashboardCard href="<?= $onlyPodcastId === null ? route_to('podcast-list') : route_to('podcast-view', $onlyPodcastId) ?>" glyph="mic-fill" title="<?= lang('Dashboard.podcasts.title') ?>" subtitle="<?= $podcastsData['last_published_at'] ? esc(lang('Dashboard.podcasts.last_published', [
'lastPublicationDate' => local_date($podcastsData['last_published_at']), 'lastPublicationDate' => local_date($podcastsData['last_published_at']),
], null, false)) : lang('Dashboard.podcasts.not_found') ?>"><?= $podcastsData['number_of_podcasts'] ?></DashboardCard> ], null, false)) : lang('Dashboard.podcasts.not_found') ?>"><?= $podcastsData['number_of_podcasts'] ?></x-DashboardCard>
<?php // @icon('play-fill')?> <?php // @icon('play-fill')?>
<DashboardCard href="<?= $onlyPodcastId === null ? '' : route_to('episode-list', $onlyPodcastId) ?>" glyph="play-fill" title="<?= lang('Dashboard.episodes.title') ?>" subtitle="<?= $episodesData['last_published_at'] ? esc(lang('Dashboard.episodes.last_published', [ <x-DashboardCard href="<?= $onlyPodcastId === null ? '' : route_to('episode-list', $onlyPodcastId) ?>" glyph="play-fill" title="<?= lang('Dashboard.episodes.title') ?>" subtitle="<?= $episodesData['last_published_at'] ? esc(lang('Dashboard.episodes.last_published', [
'lastPublicationDate' => local_date($episodesData['last_published_at']), 'lastPublicationDate' => local_date($episodesData['last_published_at']),
], null, false)) : lang('Dashboard.episodes.not_found') ?>"><?= $episodesData['number_of_episodes'] ?></DashboardCard> ], null, false)) : lang('Dashboard.episodes.not_found') ?>"><?= $episodesData['number_of_episodes'] ?></x-DashboardCard>
<?php // @icon('database-2-fill')?> <?php // @icon('database-2-fill')?>
<DashboardCard glyph="database-2-fill" title="<?= lang('Dashboard.storage.title') ?>" subtitle="<?= lang('Dashboard.storage.subtitle', [ <x-DashboardCard glyph="database-2-fill" title="<?= lang('Dashboard.storage.title') ?>" subtitle="<?= lang('Dashboard.storage.subtitle', [
'totalUploaded' => $storageData['total_uploaded'], 'totalUploaded' => $storageData['total_uploaded'],
'totalStorage' => $storageData['limit'], 'totalStorage' => $storageData['limit'],
]) ?>"><?= $storageData['percentage'] ?>%</DashboardCard> ]) ?>"><?= $storageData['percentage'] ?>%</x-DashboardCard>
</div> </div>
<div class="grid grid-cols-1 gap-4 mt-4 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-4 mt-4 lg:grid-cols-2">
<Charts.XY class="col-span-1" title="<?= lang('Charts.total_storage_by_month') ?>" dataUrl="<?= route_to( <x-Charts.XY class="col-span-1" title="<?= lang('Charts.total_storage_by_month') ?>" dataUrl="<?= route_to(
'analytics-data-instance', 'analytics-data-instance',
'Podcast', 'Podcast',
'TotalStorageByMonth', 'TotalStorageByMonth',
) ?>" /> ) ?>" />
<Charts.XY class="col-span-1" title="<?= lang('Charts.total_bandwidth_by_month') ?>" subtitle="<?= $bandwidthLimit !== null ? lang('Charts.total_bandwidth_by_month_limit', [ <x-Charts.XY class="col-span-1" title="<?= lang('Charts.total_bandwidth_by_month') ?>" subtitle="<?= $bandwidthLimit !== null ? lang('Charts.total_bandwidth_by_month_limit', [
'totalBandwidth' => $bandwidthLimit, 'totalBandwidth' => $bandwidthLimit,
]) : '' ?>" dataUrl="<?= route_to( ]) : '' ?>" dataUrl="<?= route_to(
'analytics-data-instance', 'analytics-data-instance',

View File

@ -19,7 +19,7 @@
<span class="font-semibold leading-tight line-clamp-2"><?= esc($episode->title) ?></span> <span class="font-semibold leading-tight line-clamp-2"><?= esc($episode->title) ?></span>
</div> </div>
</a> </a>
<button class="absolute top-0 right-0 z-10 p-2 mt-2 mr-2 text-white transition -translate-y-12 rounded-full opacity-0 focus:ring-accent focus:opacity-100 focus:-translate-y-0 group-hover:translate-y-0 bg-black/50 group-hover:opacity-100" id="more-dropdown-<?= $episode->id ?>" data-dropdown="button" data-dropdown-target="more-dropdown-<?= $episode->id ?>-menu" aria-haspopup="true" aria-expanded="false" title="<?= lang('Common.more') ?>"><?= icon('more-2-fill') ?></button> <button class="absolute top-0 right-0 z-10 p-2 mt-2 mr-2 text-white transition -translate-y-12 rounded-full opacity-0 focus:opacity-100 focus:-translate-y-0 group-hover:translate-y-0 bg-black/50 group-hover:opacity-100" id="more-dropdown-<?= $episode->id ?>" data-dropdown="button" data-dropdown-target="more-dropdown-<?= $episode->id ?>-menu" aria-haspopup="true" aria-expanded="false" title="<?= lang('Common.more') ?>"><?= icon('more-2-fill') ?></button>
<?php $items = [ <?php $items = [
[ [
'type' => 'link', 'type' => 'link',
@ -75,5 +75,5 @@ if ($episode->published_at === null) {
HTML), HTML),
]; ];
} ?> } ?>
<DropdownMenu id="more-dropdown-<?= $episode->id ?>-menu" labelledby="more-dropdown-<?= $episode->id ?>" offsetY="-32" items="<?= esc(json_encode($items)) ?>" /> <x-DropdownMenu id="more-dropdown-<?= $episode->id ?>-menu" labelledby="more-dropdown-<?= $episode->id ?>" offsetY="-32" items="<?= esc(json_encode($items)) ?>" />
</article> </article>

View File

@ -35,13 +35,13 @@ $episodeNavigation = [
foreach (plugins()->getPluginsWithEpisodeSettings() as $plugin) { foreach (plugins()->getPluginsWithEpisodeSettings() as $plugin) {
$route = route_to('plugins-episode-settings', $podcast->id, $episode->id, $plugin->getKey()); $route = route_to('plugins-episode-settings', $podcast->id, $episode->id, $plugin->getKey());
$episodeNavigation['plugins']['items'][] = $route; $episodeNavigation['plugins']['items'][] = $route;
$episodeNavigation['plugins']['items-labels'][] = $plugin->getName(); $episodeNavigation['plugins']['items-labels'][$route] = $plugin->getName();
$episodeNavigation['plugins']['items-permissions'][$route] = 'episodes.edit'; $episodeNavigation['plugins']['items-permissions'][$route] = 'episodes.edit';
} }
?> ?>
<a href="<?= route_to('podcast-view', $podcast->id) ?>" class="flex items-center px-4 py-2 focus:ring-inset focus:ring-accent"> <a href="<?= route_to('podcast-view', $podcast->id) ?>" class="flex items-center px-4 py-2">
<?= icon('arrow-left-line', [ <?= icon('arrow-left-line', [
'class' => 'mr-2', 'class' => 'mr-2',
]) ?> ]) ?>
@ -71,7 +71,7 @@ foreach (plugins()->getPluginsWithEpisodeSettings() as $plugin) {
'episode', 'episode',
esc($podcast->handle), esc($podcast->handle),
esc($episode->slug), esc($episode->slug),
) ?>" class="inline-flex items-center text-xs hover:underline focus:ring-accent"><?= lang( ) ?>" class="inline-flex items-center text-xs hover:underline"><?= lang(
'EpisodeNavigation.go_to_page', 'EpisodeNavigation.go_to_page',
) ?> ) ?>
<?= icon('external-link-fill', [ <?= icon('external-link-fill', [

View File

@ -15,20 +15,20 @@
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Section title="<?= lang('Episode.form.info_section_title') ?>" > <x-Forms.Section title="<?= lang('Episode.form.info_section_title') ?>" >
<Forms.Field <x-Forms.Field
name="audio_file" name="audio_file"
label="<?= esc(lang('Episode.form.audio_file')) ?>" label="<?= esc(lang('Episode.form.audio_file')) ?>"
hint="<?= esc(lang('Episode.form.audio_file_hint')) ?>" hint="<?= esc(lang('Episode.form.audio_file_hint')) ?>"
helper="<?= esc(lang('Common.size_limit', [formatBytes(file_upload_max_size(), true)])) ?>" helper="<?= esc(lang('Common.size_limit', [formatBytes(file_upload_max_size(), true)])) ?>"
type="file" type="file"
accept=".mp3,.m4a" accept=".mp3,.m4a"
required="true" isRequired="true"
data-max-size="<?= file_upload_max_size() ?>" data-max-size="<?= file_upload_max_size() ?>"
data-max-size-error="<?= lang('Episode.form.file_size_error', [formatBytes(file_upload_max_size(), true)]) ?>" /> data-max-size-error="<?= lang('Episode.form.file_size_error', [formatBytes(file_upload_max_size(), true)]) ?>" />
<Forms.Field <x-Forms.Field
name="cover" name="cover"
label="<?= esc(lang('Episode.form.cover')) ?>" label="<?= esc(lang('Episode.form.cover')) ?>"
hint="<?= esc(lang('Episode.form.cover_hint')) ?>" hint="<?= esc(lang('Episode.form.cover_hint')) ?>"
@ -36,30 +36,30 @@
type="file" type="file"
accept=".jpg,.jpeg,.png" /> accept=".jpg,.jpeg,.png" />
<Forms.Field <x-Forms.Field
name="title" name="title"
label="<?= esc(lang('Episode.form.title')) ?>" label="<?= esc(lang('Episode.form.title')) ?>"
hint="<?= esc(lang('Episode.form.title_hint')) ?>" hint="<?= esc(lang('Episode.form.title_hint')) ?>"
required="true" isRequired="true"
data-slugify="title" /> data-slugify="title" />
<div> <div>
<Forms.Label for="slug"><?= lang('Episode.form.permalink') ?></Forms.Label> <x-Forms.Label for="slug"><?= lang('Episode.form.permalink') ?></x-Forms.Label>
<permalink-edit class="inline-flex items-center w-full text-xs" edit-label="<?= lang('Common.edit') ?>" copy-label="<?= lang('Common.copy') ?>" copied-label="<?= lang('Common.copied') ?>" permalink-base="<?= url_to('podcast-episodes', $podcast->handle) ?>"> <permalink-edit class="inline-flex items-center w-full text-xs" edit-label="<?= lang('Common.edit') ?>" copy-label="<?= lang('Common.copy') ?>" copied-label="<?= lang('Common.copied') ?>" permalink-base="<?= url_to('podcast-episodes', $podcast->handle) ?>">
<span slot="domain"><?= '…/' . esc($podcast->at_handle) . '/' ?></span> <span slot="domain"><?= '…/' . esc($podcast->at_handle) . '/' ?></span>
<Forms.Input name="slug" required="true" data-slugify="slug" slot="slug-input" class="flex-1 text-xs" /> <x-Forms.Input name="slug" isRequired="true" data-slugify="slug" slot="slug-input" class="flex-1 text-xs" />
</permalink-edit> </permalink-edit>
</div> </div>
<div class="flex flex-col gap-x-2 gap-y-4 md:flex-row"> <div class="flex flex-col gap-x-2 gap-y-4 md:flex-row">
<Forms.Field <x-Forms.Field
class="flex-1 w-full" class="flex-1 w-full"
name="season_number" name="season_number"
label="<?= esc(lang('Episode.form.season_number')) ?>" label="<?= esc(lang('Episode.form.season_number')) ?>"
type="number" type="number"
value="<?= $currentSeasonNumber ?>" value="<?= $currentSeasonNumber ?>"
/> />
<Forms.Field <x-Forms.Field
class="flex-1 w-full" class="flex-1 w-full"
name="episode_number" name="episode_number"
label="<?= esc(lang('Episode.form.episode_number')) ?>" label="<?= esc(lang('Episode.form.episode_number')) ?>"
@ -71,59 +71,56 @@
<fieldset class="flex gap-1"> <fieldset class="flex gap-1">
<legend><?= lang('Episode.form.type.label') ?></legend> <legend><?= lang('Episode.form.type.label') ?></legend>
<Forms.RadioButton <x-Forms.RadioButton
value="full" value="full"
name="type" name="type"
hint="<?= esc(lang('Episode.form.type.full_hint')) ?>" hint="<?= esc(lang('Episode.form.type.full_hint')) ?>"
isChecked="true" ><?= lang('Episode.form.type.full') ?></Forms.RadioButton> isChecked="true" ><?= lang('Episode.form.type.full') ?></x-Forms.RadioButton>
<Forms.RadioButton <x-Forms.RadioButton
value="trailer" value="trailer"
name="type" name="type"
hint="<?= esc(lang('Episode.form.type.trailer_hint')) ?>" hint="<?= esc(lang('Episode.form.type.trailer_hint')) ?>"
isChecked="false" ><?= lang('Episode.form.type.trailer') ?></Forms.RadioButton> isChecked="false" ><?= lang('Episode.form.type.trailer') ?></x-Forms.RadioButton>
<Forms.RadioButton <x-Forms.RadioButton
value="bonus" value="bonus"
name="type" name="type"
hint="<?= esc(lang('Episode.form.type.bonus_hint')) ?>" hint="<?= esc(lang('Episode.form.type.bonus_hint')) ?>"
isChecked="false" ><?= lang('Episode.form.type.bonus') ?></Forms.RadioButton> isChecked="false" ><?= lang('Episode.form.type.bonus') ?></x-Forms.RadioButton>
</fieldset> </fieldset>
<fieldset class="flex gap-1"> <fieldset class="flex gap-1">
<legend> <legend><?= lang('Episode.form.parental_advisory.label') ?><x-Hint class="ml-1"><?= lang('Episode.form.parental_advisory.hint') ?></x-Hint></legend>
<?= lang('Episode.form.parental_advisory.label') . <x-Forms.RadioButton
hint_tooltip(lang('Episode.form.parental_advisory.hint'), 'ml-1') ?>
</legend>
<Forms.RadioButton
value="undefined" value="undefined"
name="parental_advisory" name="parental_advisory"
isChecked="true" ><?= lang('Episode.form.parental_advisory.undefined') ?></Forms.RadioButton> isChecked="true" ><?= lang('Episode.form.parental_advisory.undefined') ?></x-Forms.RadioButton>
<Forms.RadioButton <x-Forms.RadioButton
value="clean" value="clean"
name="parental_advisory" name="parental_advisory"
isChecked="false" ><?= lang('Episode.form.parental_advisory.clean') ?></Forms.RadioButton> isChecked="false" ><?= lang('Episode.form.parental_advisory.clean') ?></x-Forms.RadioButton>
<Forms.RadioButton <x-Forms.RadioButton
value="explicit" value="explicit"
name="parental_advisory" name="parental_advisory"
isChecked="false" ><?= lang('Episode.form.parental_advisory.explicit') ?></Forms.RadioButton> isChecked="false" ><?= lang('Episode.form.parental_advisory.explicit') ?></x-Forms.RadioButton>
</fieldset> </fieldset>
</Forms.Section> </x-Forms.Section>
<Forms.Section <x-Forms.Section
title="<?= lang('Episode.form.show_notes_section_title') ?>" title="<?= lang('Episode.form.show_notes_section_title') ?>"
subtitle="<?= lang('Episode.form.show_notes_section_subtitle') ?>"> subtitle="<?= lang('Episode.form.show_notes_section_subtitle') ?>">
<Forms.Field <x-Forms.Field
as="MarkdownEditor" as="MarkdownEditor"
name="description" name="description"
label="<?= esc(lang('Episode.form.description')) ?>" label="<?= esc(lang('Episode.form.description')) ?>"
required="true" isRequired="true"
disallowList="header,quote" /> disallowList="header,quote" />
<Forms.Field <x-Forms.Field
as="MarkdownEditor" as="MarkdownEditor"
name="description_footer" name="description_footer"
label="<?= esc(lang('Episode.form.description_footer')) ?>" label="<?= esc(lang('Episode.form.description_footer')) ?>"
@ -131,32 +128,31 @@
value="<?= esc($podcast->episode_description_footer_markdown) ?? '' ?>" value="<?= esc($podcast->episode_description_footer_markdown) ?? '' ?>"
disallowList="header,quote" /> disallowList="header,quote" />
</Forms.Section> </x-Forms.Section>
<Forms.Section title="<?= lang('Episode.form.premium_title') ?>"> <x-Forms.Section title="<?= lang('Episode.form.premium_title') ?>">
<Forms.Toggler class="mt-2" name="premium" value="yes" checked="<?= $podcast->is_premium_by_default ? 'true' : 'false' ?>"> <x-Forms.Toggler class="mt-2" name="premium" isChecked="<?= $podcast->is_premium_by_default ? 'true' : 'false' ?>">
<?= lang('Episode.form.premium') ?></Forms.Toggler> <?= lang('Episode.form.premium') ?></x-Forms.Toggler>
</Forms.Section> </x-Forms.Section>
<Forms.Section <x-Forms.Section
title="<?= lang('Episode.form.location_section_title') ?>" title="<?= lang('Episode.form.location_section_title') ?>"
subtitle="<?= lang('Episode.form.location_section_subtitle') ?>" subtitle="<?= lang('Episode.form.location_section_subtitle') ?>"
> >
<Forms.Field <x-Forms.Field
name="location_name" name="location_name"
label="<?= esc(lang('Episode.form.location_name')) ?>" label="<?= esc(lang('Episode.form.location_name')) ?>"
hint="<?= esc(lang('Episode.form.location_name_hint')) ?>" /> hint="<?= esc(lang('Episode.form.location_name_hint')) ?>" />
</Forms.Section> </x-Forms.Section>
<Forms.Section <x-Forms.Section
title="<?= lang('Episode.form.additional_files_section_title') ?>"> title="<?= lang('Episode.form.additional_files_section_title') ?>">
<fieldset class="flex flex-col"> <fieldset class="flex flex-col">
<legend><?= lang('Episode.form.transcript') . <legend><?= lang('Episode.form.transcript') .
'<small class="ml-1 lowercase">(' . '<small class="ml-1 lowercase">(' .
lang('Common.optional') . lang('Common.optional') .
')</small>' . ')</small>' ?><x-Hint class="ml-1"><?= lang('Episode.form.transcript_hint') ?></x-Hint></legend>
hint_tooltip(lang('Episode.form.transcript_hint'), 'ml-1') ?></legend>
<div class="form-input-tabs"> <div class="form-input-tabs">
<input type="radio" name="transcript-choice" id="transcript-file-upload-choice" aria-controls="transcript-file-upload-choice" value="upload-file" <?= old('transcript-choice') !== 'remote-file' ? 'checked' : '' ?> /> <input type="radio" name="transcript-choice" id="transcript-file-upload-choice" aria-controls="transcript-file-upload-choice" value="upload-file" <?= old('transcript-choice') !== 'remote-file' ? 'checked' : '' ?> />
<label for="transcript-file-upload-choice"><?= lang('Common.forms.upload_file') ?></label> <label for="transcript-file-upload-choice"><?= lang('Common.forms.upload_file') ?></label>
@ -166,12 +162,12 @@
<div class="py-2 tab-panels"> <div class="py-2 tab-panels">
<section id="transcript-file-upload" class="flex items-center tab-panel"> <section id="transcript-file-upload" class="flex items-center tab-panel">
<Forms.Label class="sr-only" for="transcript_file" isOptional="true"><?= lang('Episode.form.transcript_file') ?></Forms.Label> <x-Forms.Label class="sr-only" for="transcript_file" isOptional="true"><?= lang('Episode.form.transcript_file') ?></x-Forms.Label>
<Forms.Input class="w-full" name="transcript_file" type="file" accept=".srt,.vtt" /> <x-Forms.Input class="w-full" name="transcript_file" type="file" accept=".srt,.vtt" />
</section> </section>
<section id="transcript-file-remote-url" class="tab-panel"> <section id="transcript-file-remote-url" class="tab-panel">
<Forms.Label class="sr-only" for="transcript_remote_url" isOptional="true"><?= lang('Episode.form.transcript_remote_url') ?></Forms.Label> <x-Forms.Label class="sr-only" for="transcript_remote_url" isOptional="true"><?= lang('Episode.form.transcript_remote_url') ?></x-Forms.Label>
<Forms.Input class="w-full" placeholder="https://…" name="transcript_remote_url" /> <x-Forms.Input class="w-full" placeholder="https://…" name="transcript_remote_url" />
</section> </section>
</div> </div>
</div> </div>
@ -182,8 +178,7 @@
<legend><?= lang('Episode.form.chapters') . <legend><?= lang('Episode.form.chapters') .
'<small class="ml-1 lowercase">(' . '<small class="ml-1 lowercase">(' .
lang('Common.optional') . lang('Common.optional') .
')</small>' . ')</small>' ?><x-Hint class="ml-1"><?= lang('Episode.form.chapters_hint') ?></x-Hint></legend>
hint_tooltip(lang('Episode.form.chapters_hint'), 'ml-1') ?></legend>
<div class="form-input-tabs"> <div class="form-input-tabs">
<input type="radio" name="chapters-choice" id="chapters-file-upload-choice" aria-controls="chapters-file-upload-choice" value="upload-file" <?= old('chapters-choice') !== 'remote-file' ? 'checked' : '' ?> /> <input type="radio" name="chapters-choice" id="chapters-file-upload-choice" aria-controls="chapters-file-upload-choice" value="upload-file" <?= old('chapters-choice') !== 'remote-file' ? 'checked' : '' ?> />
<label for="chapters-file-upload-choice"><?= lang('Common.forms.upload_file') ?></label> <label for="chapters-file-upload-choice"><?= lang('Common.forms.upload_file') ?></label>
@ -193,35 +188,35 @@
<div class="py-2 tab-panels"> <div class="py-2 tab-panels">
<section id="chapters-file-upload" class="flex items-center tab-panel"> <section id="chapters-file-upload" class="flex items-center tab-panel">
<Forms.Label class="sr-only" for="chapters_file" isOptional="true"><?= lang('Episode.form.chapters_file') ?></Forms.Label> <x-Forms.Label class="sr-only" for="chapters_file" isOptional="true"><?= lang('Episode.form.chapters_file') ?></x-Forms.Label>
<Forms.Input class="w-full" name="chapters_file" type="file" accept=".json" /> <x-Forms.Input class="w-full" name="chapters_file" type="file" accept=".json" />
</section> </section>
<section id="chapters-file-remote-url" class="tab-panel"> <section id="chapters-file-remote-url" class="tab-panel">
<Forms.Label class="sr-only" for="chapters_remote_url" isOptional="true"><?= lang('Episode.form.chapters_remote_url') ?></Forms.Label> <x-Forms.Label class="sr-only" for="chapters_remote_url" isOptional="true"><?= lang('Episode.form.chapters_remote_url') ?></x-Forms.Label>
<Forms.Input class="w-full" placeholder="https://…" name="chapters_remote_url" /> <x-Forms.Input class="w-full" placeholder="https://…" name="chapters_remote_url" />
</section> </section>
</div> </div>
</div> </div>
</fieldset> </fieldset>
</Forms.Section> </x-Forms.Section>
<Forms.Section <x-Forms.Section
title="<?= lang('Episode.form.advanced_section_title') ?>" title="<?= lang('Episode.form.advanced_section_title') ?>"
subtitle="<?= lang('Episode.form.advanced_section_subtitle') ?>" subtitle="<?= lang('Episode.form.advanced_section_subtitle') ?>"
> >
<Forms.Field <x-Forms.Field
as="XMLEditor" as="XMLEditor"
name="custom_rss" name="custom_rss"
label="<?= esc(lang('Episode.form.custom_rss')) ?>" label="<?= esc(lang('Episode.form.custom_rss')) ?>"
hint="<?= esc(lang('Episode.form.custom_rss_hint')) ?>" hint="<?= esc(lang('Episode.form.custom_rss_hint')) ?>"
/> />
<Forms.Toggler name="block" value="yes" checked="false" hint="<?= esc(lang('Episode.form.block_hint')) ?>"><?= lang('Episode.form.block') ?></Forms.Toggler> <x-Forms.Toggler name="block" isChecked="false" hint="<?= esc(lang('Episode.form.block_hint')) ?>"><?= lang('Episode.form.block') ?></x-Forms.Toggler>
</Forms.Section> </x-Forms.Section>
<Button class="self-end" variant="primary" type="submit"><?= lang('Episode.form.submit_create') ?></Button> <x-Button class="self-end" variant="primary" type="submit"><?= lang('Episode.form.submit_create') ?></x-Button>
</form> </form>

View File

@ -13,13 +13,13 @@
<form action="<?= route_to('episode-delete', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col w-full max-w-xl mx-auto"> <form action="<?= route_to('episode-delete', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col w-full max-w-xl mx-auto">
<?= csrf_field() ?> <?= csrf_field() ?>
<Alert variant="danger" class="font-semibold"><?= lang('Episode.delete_form.disclaimer') ?></Alert> <x-Alert variant="danger" class="font-semibold"><?= lang('Episode.delete_form.disclaimer') ?></x-Alert>
<Forms.Checkbox class="mt-2" name="understand" required="true" isChecked="false"><?= lang('Episode.delete_form.understand') ?></Forms.Checkbox> <x-Forms.Checkbox class="mt-2" name="understand" isRequired="true" isChecked="false"><?= lang('Episode.delete_form.understand') ?></x-Forms.Checkbox>
<div class="self-end mt-4"> <div class="self-end mt-4">
<Button uri="<?= route_to('episode-view', $podcast->id, $episode->id) ?>"><?= lang('Common.cancel') ?></Button> <x-Button uri="<?= route_to('episode-view', $podcast->id, $episode->id) ?>"><?= lang('Common.cancel') ?></x-Button>
<Button type="submit" variant="danger"><?= lang('Episode.delete_form.submit') ?></Button> <x-Button type="submit" variant="danger"><?= lang('Episode.delete_form.submit') ?></x-Button>
</div> </div>
</form> </form>

View File

@ -9,7 +9,7 @@
<?= $this->endSection() ?> <?= $this->endSection() ?>
<?= $this->section('headerRight') ?> <?= $this->section('headerRight') ?>
<Button variant="primary" type="submit" form="episode-edit-form"><?= lang('Episode.form.submit_edit') ?></Button> <x-Button variant="primary" type="submit" form="episode-edit-form"><?= lang('Episode.form.submit_edit') ?></x-Button>
<?= $this->endSection() ?> <?= $this->endSection() ?>
@ -19,9 +19,9 @@
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Section title="<?= lang('Episode.form.info_section_title') ?>" > <x-Forms.Section title="<?= lang('Episode.form.info_section_title') ?>" >
<Forms.Field <x-Forms.Field
name="audio_file" name="audio_file"
label="<?= esc(lang('Episode.form.audio_file')) ?>" label="<?= esc(lang('Episode.form.audio_file')) ?>"
hint="<?= esc(lang('Episode.form.audio_file_hint')) ?>" hint="<?= esc(lang('Episode.form.audio_file_hint')) ?>"
@ -31,7 +31,7 @@
data-max-size="<?= file_upload_max_size() ?>" data-max-size="<?= file_upload_max_size() ?>"
data-max-size-error="<?= lang('Episode.form.file_size_error', [formatBytes(file_upload_max_size(), true)]) ?>" /> data-max-size-error="<?= lang('Episode.form.file_size_error', [formatBytes(file_upload_max_size(), true)]) ?>" />
<Forms.Field <x-Forms.Field
name="cover" name="cover"
label="<?= esc(lang('Episode.form.cover')) ?>" label="<?= esc(lang('Episode.form.cover')) ?>"
hint="<?= esc(lang('Episode.form.cover_hint')) ?>" hint="<?= esc(lang('Episode.form.cover_hint')) ?>"
@ -39,31 +39,31 @@
type="file" type="file"
accept=".jpg,.jpeg,.png" /> accept=".jpg,.jpeg,.png" />
<Forms.Field <x-Forms.Field
name="title" name="title"
label="<?= esc(lang('Episode.form.title')) ?>" label="<?= esc(lang('Episode.form.title')) ?>"
hint="<?= esc(lang('Episode.form.title_hint')) ?>" hint="<?= esc(lang('Episode.form.title_hint')) ?>"
value="<?= esc($episode->title) ?>" value="<?= esc($episode->title) ?>"
required="true" isRequired="true"
data-slugify="title" /> data-slugify="title" />
<div> <div>
<Forms.Label for="slug"><?= lang('Episode.form.permalink') ?></Forms.Label> <x-Forms.Label for="slug"><?= lang('Episode.form.permalink') ?></x-Forms.Label>
<permalink-edit class="inline-flex items-center w-full text-xs" edit-label="<?= lang('Common.edit') ?>" copy-label="<?= lang('Common.copy') ?>" copied-label="<?= lang('Common.copied') ?>" permalink-base="<?= url_to('podcast-episodes', esc($podcast->handle)) ?>"> <permalink-edit class="inline-flex items-center w-full text-xs" edit-label="<?= lang('Common.edit') ?>" copy-label="<?= lang('Common.copy') ?>" copied-label="<?= lang('Common.copied') ?>" permalink-base="<?= url_to('podcast-episodes', esc($podcast->handle)) ?>">
<span slot="domain"><?= '…/' . esc($podcast->handle) . '/' ?></span> <span slot="domain"><?= '…/' . esc($podcast->handle) . '/' ?></span>
<Forms.Input name="slug" value="<?= esc($episode->slug) ?>" required="true" data-slugify="slug" slot="slug-input" class="flex-1 text-xs" /> <x-Forms.Input name="slug" value="<?= esc($episode->slug) ?>" isRequired="true" data-slugify="slug" slot="slug-input" class="flex-1 text-xs" />
</permalink-edit> </permalink-edit>
</div> </div>
<div class="flex flex-col gap-x-2 gap-y-4 md:flex-row"> <div class="flex flex-col gap-x-2 gap-y-4 md:flex-row">
<Forms.Field <x-Forms.Field
class="flex-1 w-full" class="flex-1 w-full"
name="season_number" name="season_number"
label="<?= esc(lang('Episode.form.season_number')) ?>" label="<?= esc(lang('Episode.form.season_number')) ?>"
type="number" type="number"
value="<?= $episode->season_number ?>" value="<?= $episode->season_number ?>"
/> />
<Forms.Field <x-Forms.Field
class="flex-1 w-full" class="flex-1 w-full"
name="episode_number" name="episode_number"
label="<?= esc(lang('Episode.form.episode_number')) ?>" label="<?= esc(lang('Episode.form.episode_number')) ?>"
@ -75,57 +75,54 @@
<fieldset class="flex gap-1"> <fieldset class="flex gap-1">
<legend><?= lang('Episode.form.type.label') ?></legend> <legend><?= lang('Episode.form.type.label') ?></legend>
<Forms.RadioButton <x-Forms.RadioButton
value="full" value="full"
name="type" name="type"
hint="<?= esc(lang('Episode.form.type.full_hint')) ?>" hint="<?= esc(lang('Episode.form.type.full_hint')) ?>"
isChecked="<?= $episode->type === 'full' ? 'true' : 'false' ?>" ><?= lang('Episode.form.type.full') ?></Forms.RadioButton> isChecked="<?= $episode->type === 'full' ? 'true' : 'false' ?>" ><?= lang('Episode.form.type.full') ?></x-Forms.RadioButton>
<Forms.RadioButton <x-Forms.RadioButton
value="trailer" value="trailer"
name="type" name="type"
hint="<?= esc(lang('Episode.form.type.trailer_hint')) ?>" hint="<?= esc(lang('Episode.form.type.trailer_hint')) ?>"
isChecked="<?= $episode->type === 'trailer' ? 'true' : 'false' ?>" ><?= lang('Episode.form.type.trailer') ?></Forms.RadioButton> isChecked="<?= $episode->type === 'trailer' ? 'true' : 'false' ?>" ><?= lang('Episode.form.type.trailer') ?></x-Forms.RadioButton>
<Forms.RadioButton <x-Forms.RadioButton
value="bonus" value="bonus"
name="type" name="type"
hint="<?= esc(lang('Episode.form.type.bonus_hint')) ?>" hint="<?= esc(lang('Episode.form.type.bonus_hint')) ?>"
isChecked="<?= $episode->type === 'bonus' ? 'true' : 'false' ?>" ><?= lang('Episode.form.type.bonus') ?></Forms.RadioButton> isChecked="<?= $episode->type === 'bonus' ? 'true' : 'false' ?>" ><?= lang('Episode.form.type.bonus') ?></x-Forms.RadioButton>
</fieldset> </fieldset>
<fieldset class="flex gap-1"> <fieldset class="flex gap-1">
<legend> <legend><?= lang('Episode.form.parental_advisory.label') ?><x-Hint class="ml-1"><?= lang('Episode.form.parental_advisory.hint') ?></x-Hint></legend>
<?= lang('Episode.form.parental_advisory.label') . <x-Forms.RadioButton
hint_tooltip(lang('Episode.form.parental_advisory.hint'), 'ml-1') ?>
</legend>
<Forms.RadioButton
value="undefined" value="undefined"
name="parental_advisory" name="parental_advisory"
isChecked="<?= $episode->parental_advisory === null ? 'true' : 'false' ?>" ><?= lang('Episode.form.parental_advisory.undefined') ?></Forms.RadioButton> isChecked="<?= $episode->parental_advisory === null ? 'true' : 'false' ?>" ><?= lang('Episode.form.parental_advisory.undefined') ?></x-Forms.RadioButton>
<Forms.RadioButton <x-Forms.RadioButton
value="clean" value="clean"
name="parental_advisory" name="parental_advisory"
isChecked="<?= $episode->parental_advisory === 'clean' ? 'true' : 'false' ?>" ><?= lang('Episode.form.parental_advisory.clean') ?></Forms.RadioButton> isChecked="<?= $episode->parental_advisory === 'clean' ? 'true' : 'false' ?>" ><?= lang('Episode.form.parental_advisory.clean') ?></x-Forms.RadioButton>
<Forms.RadioButton <x-Forms.RadioButton
value="explicit" value="explicit"
name="parental_advisory" name="parental_advisory"
isChecked="<?= $episode->parental_advisory === 'explicit' ? 'true' : 'false' ?>" ><?= lang('Episode.form.parental_advisory.explicit') ?></Forms.RadioButton> isChecked="<?= $episode->parental_advisory === 'explicit' ? 'true' : 'false' ?>" ><?= lang('Episode.form.parental_advisory.explicit') ?></x-Forms.RadioButton>
</fieldset> </fieldset>
</Forms.Section> </x-Forms.Section>
<Forms.Section <x-Forms.Section
title="<?= lang('Episode.form.show_notes_section_title') ?>" title="<?= lang('Episode.form.show_notes_section_title') ?>"
subtitle="<?= lang('Episode.form.show_notes_section_subtitle') ?>"> subtitle="<?= lang('Episode.form.show_notes_section_subtitle') ?>">
<Forms.Field <x-Forms.Field
as="MarkdownEditor" as="MarkdownEditor"
name="description" name="description"
label="<?= esc(lang('Episode.form.description')) ?>" label="<?= esc(lang('Episode.form.description')) ?>"
value="<?= esc($episode->description_markdown) ?>" value="<?= esc($episode->description_markdown) ?>"
required="true" isRequired="true"
disallowList="header,quote" /> disallowList="header,quote" />
<Forms.Field <x-Forms.Field
as="MarkdownEditor" as="MarkdownEditor"
name="description_footer" name="description_footer"
label="<?= esc(lang('Episode.form.description_footer')) ?>" label="<?= esc(lang('Episode.form.description_footer')) ?>"
@ -133,33 +130,32 @@
value="<?= esc($podcast->episode_description_footer_markdown) ?? '' ?>" value="<?= esc($podcast->episode_description_footer_markdown) ?? '' ?>"
disallowList="header,quote" /> disallowList="header,quote" />
</Forms.Section> </x-Forms.Section>
<Forms.Section title="<?= lang('Episode.form.premium_title') ?>" > <x-Forms.Section title="<?= lang('Episode.form.premium_title') ?>" >
<Forms.Toggler class="mt-2" name="premium" value="yes" checked="<?= $episode->is_premium ? 'true' : 'false' ?>"> <x-Forms.Toggler class="mt-2" name="premium" isChecked="<?= $episode->is_premium ? 'true' : 'false' ?>">
<?= lang('Episode.form.premium') ?></Forms.Toggler> <?= lang('Episode.form.premium') ?></x-Forms.Toggler>
</Forms.Section> </x-Forms.Section>
<Forms.Section <x-Forms.Section
title="<?= lang('Episode.form.location_section_title') ?>" title="<?= lang('Episode.form.location_section_title') ?>"
subtitle="<?= lang('Episode.form.location_section_subtitle') ?>" subtitle="<?= lang('Episode.form.location_section_subtitle') ?>"
> >
<Forms.Field <x-Forms.Field
name="location_name" name="location_name"
label="<?= esc(lang('Episode.form.location_name')) ?>" label="<?= esc(lang('Episode.form.location_name')) ?>"
hint="<?= esc(lang('Episode.form.location_name_hint')) ?>" hint="<?= esc(lang('Episode.form.location_name_hint')) ?>"
value="<?= esc($episode->location_name) ?>" /> value="<?= esc($episode->location_name) ?>" />
</Forms.Section> </x-Forms.Section>
<Forms.Section <x-Forms.Section
title="<?= lang('Episode.form.additional_files_section_title') ?>"> title="<?= lang('Episode.form.additional_files_section_title') ?>">
<fieldset class="flex flex-col"> <fieldset class="flex flex-col">
<legend><?= lang('Episode.form.transcript') . <legend><?= lang('Episode.form.transcript') .
'<small class="ml-1 lowercase">(' . '<small class="ml-1 lowercase">(' .
lang('Common.optional') . lang('Common.optional') .
')</small>' . ')</small>' ?><x-Hint class="ml-1"><?= lang('Episode.form.transcript_hint') ?></x-Hint></legend>
hint_tooltip(lang('Episode.form.transcript_hint'), 'ml-1') ?></legend>
<div class="form-input-tabs"> <div class="form-input-tabs">
<input type="radio" name="transcript-choice" id="transcript-file-upload-choice" aria-controls="transcript-file-upload-choice" value="upload-file" <?= $episode->transcript_remote_url ? '' : 'checked' ?> /> <input type="radio" name="transcript-choice" id="transcript-file-upload-choice" aria-controls="transcript-file-upload-choice" value="upload-file" <?= $episode->transcript_remote_url ? '' : 'checked' ?> />
<label for="transcript-file-upload-choice"><?= lang('Common.forms.upload_file') ?></label> <label for="transcript-file-upload-choice"><?= lang('Common.forms.upload_file') ?></label>
@ -191,7 +187,7 @@
'class' => 'mx-auto', 'class' => 'mx-auto',
]), ]),
[ [
'class' => 'p-1 text-sm bg-red-100 rounded-full text-red-700 hover:text-red-900 focus:ring-accent', 'class' => 'p-1 text-sm bg-red-100 rounded-full text-red-700 hover:text-red-900',
'data-tooltip' => 'bottom', 'data-tooltip' => 'bottom',
'title' => lang( 'title' => lang(
'Episode.form.transcript_file_delete', 'Episode.form.transcript_file_delete',
@ -200,12 +196,12 @@
) ?> ) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<Forms.Label class="sr-only" for="transcript_file" isOptional="true"><?= lang('Episode.form.transcript_file') ?></Forms.Label> <x-Forms.Label class="sr-only" for="transcript_file" isOptional="true"><?= lang('Episode.form.transcript_file') ?></x-Forms.Label>
<Forms.Input class="w-full" name="transcript_file" type="file" accept=".srt,.vtt" /> <x-Forms.Input class="w-full" name="transcript_file" type="file" accept=".srt,.vtt" />
</section> </section>
<section id="transcript-file-remote-url" class="tab-panel"> <section id="transcript-file-remote-url" class="tab-panel">
<Forms.Label class="sr-only" for="transcript_remote_url" isOptional="true"><?= lang('Episode.form.transcript_remote_url') ?></Forms.Label> <x-Forms.Label class="sr-only" for="transcript_remote_url" isOptional="true"><?= lang('Episode.form.transcript_remote_url') ?></x-Forms.Label>
<Forms.Input class="w-full" placeholder="https://…" name="transcript_remote_url" value="<?= esc($episode->transcript_remote_url) ?>" /> <x-Forms.Input class="w-full" placeholder="https://…" name="transcript_remote_url" value="<?= esc($episode->transcript_remote_url) ?>" />
</section> </section>
</div> </div>
</div> </div>
@ -216,8 +212,7 @@
<legend><?= lang('Episode.form.chapters') . <legend><?= lang('Episode.form.chapters') .
'<small class="ml-1 lowercase">(' . '<small class="ml-1 lowercase">(' .
lang('Common.optional') . lang('Common.optional') .
')</small>' . ')</small>' ?><x-Hint class="ml-1"><?= lang('Episode.form.chapters_hint') ?></x-Hint></legend>
hint_tooltip(lang('Episode.form.chapters_hint'), 'ml-1') ?></legend>
<div class="form-input-tabs"> <div class="form-input-tabs">
<input type="radio" name="chapters-choice" id="chapters-file-upload-choice" aria-controls="chapters-file-upload-choice" value="upload-file" <?= $episode->chapters_remote_url ? '' : 'checked' ?> /> <input type="radio" name="chapters-choice" id="chapters-file-upload-choice" aria-controls="chapters-file-upload-choice" value="upload-file" <?= $episode->chapters_remote_url ? '' : 'checked' ?> />
<label for="chapters-file-upload-choice"><?= lang('Common.forms.upload_file') ?></label> <label for="chapters-file-upload-choice"><?= lang('Common.forms.upload_file') ?></label>
@ -249,7 +244,7 @@
'class' => 'mx-auto', 'class' => 'mx-auto',
]), ]),
[ [
'class' => 'text-sm p-1 bg-red-100 rounded-full text-red-700 hover:text-red-900 focus:ring-accent', 'class' => 'text-sm p-1 bg-red-100 rounded-full text-red-700 hover:text-red-900',
'data-tooltip' => 'bottom', 'data-tooltip' => 'bottom',
'title' => lang( 'title' => lang(
'Episode.form.chapters_file_delete', 'Episode.form.chapters_file_delete',
@ -258,23 +253,23 @@
) ?> ) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<Forms.Label class="sr-only" for="chapters_file" isOptional="true"><?= lang('Episode.form.chapters_file') ?></Forms.Label> <x-Forms.Label class="sr-only" for="chapters_file" isOptional="true"><?= lang('Episode.form.chapters_file') ?></x-Forms.Label>
<Forms.Input class="w-full" name="chapters_file" type="file" accept=".json" /> <x-Forms.Input class="w-full" name="chapters_file" type="file" accept=".json" />
</section> </section>
<section id="chapters-file-remote-url" class="tab-panel"> <section id="chapters-file-remote-url" class="tab-panel">
<Forms.Label class="sr-only" for="chapters_remote_url" isOptional="true"><?= lang('Episode.form.chapters_remote_url') ?></Forms.Label> <x-Forms.Label class="sr-only" for="chapters_remote_url" isOptional="true"><?= lang('Episode.form.chapters_remote_url') ?></x-Forms.Label>
<Forms.Input class="w-full" placeholder="https://…" name="chapters_remote_url" value="<?= esc($episode->chapters_remote_url) ?>" /> <x-Forms.Input class="w-full" placeholder="https://…" name="chapters_remote_url" value="<?= esc($episode->chapters_remote_url) ?>" />
</section> </section>
</div> </div>
</div> </div>
</fieldset> </fieldset>
</Forms.Section> </x-Forms.Section>
<Forms.Section <x-Forms.Section
title="<?= lang('Episode.form.advanced_section_title') ?>" title="<?= lang('Episode.form.advanced_section_title') ?>"
subtitle="<?= lang('Episode.form.advanced_section_subtitle') ?>" subtitle="<?= lang('Episode.form.advanced_section_subtitle') ?>"
> >
<Forms.Field <x-Forms.Field
as="XMLEditor" as="XMLEditor"
name="custom_rss" name="custom_rss"
label="<?= esc(lang('Episode.form.custom_rss')) ?>" label="<?= esc(lang('Episode.form.custom_rss')) ?>"
@ -282,18 +277,18 @@
content="<?= esc($episode->custom_rss_string) ?>" content="<?= esc($episode->custom_rss_string) ?>"
/> />
<Forms.Toggler id="block" name="block" value="yes" checked="<?= $episode->is_blocked ? 'true' : 'false' ?>" hint="<?= esc(lang('Episode.form.block_hint')) ?>"><?= lang('Episode.form.block') ?></Forms.Toggler> <x-Forms.Toggler id="block" name="block" isChecked="<?= $episode->is_blocked ? 'true' : 'false' ?>" hint="<?= esc(lang('Episode.form.block_hint')) ?>"><?= lang('Episode.form.block') ?></x-Forms.Toggler>
</Forms.Section> </x-Forms.Section>
</form> </form>
<?php if ($episode->published_at === null): ?> <?php if ($episode->published_at === null): ?>
<?php // @icon('delete-bin-fill')?> <?php // @icon('delete-bin-fill')?>
<Button class="mt-8" variant="danger" uri="<?= route_to('episode-delete', $podcast->id, $episode->id) ?>" iconLeft="delete-bin-fill"><?= lang('Episode.delete') ?></Button> <x-Button class="mt-8" variant="danger" uri="<?= route_to('episode-delete', $podcast->id, $episode->id) ?>" iconLeft="delete-bin-fill"><?= lang('Episode.delete') ?></x-Button>
<?php else: ?> <?php else: ?>
<?php // @icon('forbid-fill')?> <?php // @icon('forbid-fill')?>
<Button class="mt-8" variant="disabled" iconLeft="forbid-fill" data-tooltip="right" title="<?= lang('Episode.messages.unpublishBeforeDeleteTip') ?>"><?= lang('Episode.delete') ?></Button> <x-Button class="mt-8" variant="disabled" iconLeft="forbid-fill" data-tooltip="right" title="<?= lang('Episode.messages.unpublishBeforeDeleteTip') ?>"><?= lang('Episode.delete') ?></x-Button>
<?php endif ?> <?php endif ?>

View File

@ -33,15 +33,15 @@ $embedHeight = config('Embed')->height;
<iframe name="embed" id="embed" class="w-full max-w-xl mt-6 h-28" frameborder="0" scrolling="no" style="width: 100%; overflow: hidden;" src="<?= $episode->embed_url ?>"></iframe> <iframe name="embed" id="embed" class="w-full max-w-xl mt-6 h-28" frameborder="0" scrolling="no" style="width: 100%; overflow: hidden;" src="<?= $episode->embed_url ?>"></iframe>
<div class="flex items-center mt-8 gap-x-2"> <div class="flex items-center mt-8 gap-x-2">
<Forms.Textarea readonly="true" class="w-full max-w-xl" name="iframe" rows="2" value="<?= esc("<iframe width=\"100%\" height=\"{$embedHeight}\" frameborder=\"0\" scrolling=\"no\" style=\"width: 100%; height: {$embedHeight}px; overflow: hidden;\" src=\"{$episode->embed_url}\"></iframe>") ?>" /> <x-Forms.Textarea isReadonly="true" class="w-full max-w-xl" name="iframe" rows="2" value="<?= esc("<iframe width=\"100%\" height=\"{$embedHeight}\" frameborder=\"0\" scrolling=\"no\" style=\"width: 100%; height: {$embedHeight}px; overflow: hidden;\" src=\"{$episode->embed_url}\"></iframe>") ?>" />
<?php // @icon('file-copy-fill')?> <?php // @icon('file-copy-fill')?>
<IconButton glyph="file-copy-fill" data-type="clipboard-copy" data-clipboard-target="iframe"><?= lang('Episode.embed.clipboard_iframe') ?></IconButton> <x-IconButton glyph="file-copy-fill" data-type="clipboard-copy" data-clipboard-target="iframe"><?= lang('Episode.embed.clipboard_iframe') ?></x-IconButton>
</div> </div>
<div class="flex items-center mt-4 gap-x-2"> <div class="flex items-center mt-4 gap-x-2">
<Forms.Input readonly="true" class="w-full max-w-xl" name="url" value="<?= esc($episode->embed_url) ?>" /> <x-Forms.Input isReadonly="true" class="w-full max-w-xl" name="url" value="<?= esc($episode->embed_url) ?>" />
<?php // @icon('file-copy-fill')?> <?php // @icon('file-copy-fill')?>
<IconButton glyph="file-copy-fill" data-type="clipboard-copy" data-clipboard-target="url"><?= lang('Episode.embed.clipboard_url') ?></IconButton> <x-IconButton glyph="file-copy-fill" data-type="clipboard-copy" data-clipboard-target="url"><?= lang('Episode.embed.clipboard_url') ?></x-IconButton>
</div> </div>
<?= $this->endSection() ?> <?= $this->endSection() ?>

View File

@ -10,7 +10,7 @@
<?= $this->section('headerRight') ?> <?= $this->section('headerRight') ?>
<?php // @icon('add-fill')?> <?php // @icon('add-fill')?>
<Button uri="<?= route_to('episode-create', $podcast->id) ?>" variant="primary" iconLeft="add-fill"><?= lang('Episode.create') ?></Button> <x-Button uri="<?= route_to('episode-create', $podcast->id) ?>" variant="primary" iconLeft="add-fill"><?= lang('Episode.create') ?></x-Button>
<?= $this->endSection() ?> <?= $this->endSection() ?>
@ -28,16 +28,16 @@
</p> </p>
<form class="relative flex"> <form class="relative flex">
<div class="relative"> <div class="relative">
<Forms.Input name="q" placeholder="<?= lang('Episode.list.search.placeholder') ?>" value="<?= esc($query) ?>" class="<?= $query ? 'pr-8' : '' ?>" /> <x-Forms.Input name="q" placeholder="<?= lang('Episode.list.search.placeholder') ?>" value="<?= esc($query) ?>" class="<?= $query ? 'pr-8' : '' ?>" />
<?php if ($query): ?> <?php if ($query): ?>
<a href="<?= route_to('episode-list', $podcast->id) ?>" class="absolute inset-y-0 right-0 inline-flex items-center justify-center px-2 opacity-75 focus:ring-accent hover:opacity-100 focus:opacity-100" title="<?= lang('Episode.list.search.clear') ?>" data-tooltip="bottom"><?= icon('close-fill', [ <a href="<?= route_to('episode-list', $podcast->id) ?>" class="absolute inset-y-0 right-0 inline-flex items-center justify-center px-2 opacity-75 hover:opacity-100 focus:opacity-100" title="<?= lang('Episode.list.search.clear') ?>" data-tooltip="bottom"><?= icon('close-fill', [
'class' => 'text-lg', 'class' => 'text-lg',
]) ?></a> ]) ?></a>
<?php endif; ?> <?php endif; ?>
</div> </div>
<Button type="submit" variant="secondary" class="px-3 ml-2 rounded-lg shadow-md" title="<?= lang('Episode.list.search.submit') ?>" data-tooltip="bottom" isSquared="true"><?= icon('search-fill', [ <x-Button type="submit" variant="secondary" class="px-3 ml-2 rounded-lg shadow-md" title="<?= lang('Episode.list.search.submit') ?>" data-tooltip="bottom" isSquared="true"><?= icon('search-fill', [
'class' => 'text-xl', 'class' => 'text-xl',
]) ?></Button> ]) ?></x-Button>
</form> </form>
</div> </div>
@ -161,10 +161,10 @@ data_table(
HTML), HTML),
]; ];
} }
return '<button id="more-dropdown-' . $episode->id . '" type="button" class="inline-flex items-center p-1 rounded-full focus:ring-accent" data-dropdown="button" data-dropdown-target="more-dropdown-' . $episode->id . '-menu" aria-haspopup="true" aria-expanded="false">' . return '<button id="more-dropdown-' . $episode->id . '" type="button" class="inline-flex items-center p-1 rounded-full" data-dropdown="button" data-dropdown-target="more-dropdown-' . $episode->id . '-menu" aria-haspopup="true" aria-expanded="false">' .
icon('more-2-fill') . icon('more-2-fill') .
'</button>' . '</button>' .
'<DropdownMenu id="more-dropdown-' . $episode->id . '-menu" labelledby="more-dropdown-' . $episode->id . '" offsetY="-24" items="' . esc(json_encode($items)) . '" />'; '<x-DropdownMenu id="more-dropdown-' . $episode->id . '-menu" labelledby="more-dropdown-' . $episode->id . '" offsetY="-24" items="' . esc(json_encode($items)) . '" />';
}, },
], ],
], ],

View File

@ -10,7 +10,7 @@
<?= $this->section('headerRight') ?> <?= $this->section('headerRight') ?>
<?php // @icon('add-fill')?> <?php // @icon('add-fill')?>
<Button variant="primary" uri="<?= route_to('person-create') ?>" iconLeft="add-fill"><?= lang('Person.create') ?></Button> <x-Button variant="primary" uri="<?= route_to('person-create') ?>" iconLeft="add-fill"><?= lang('Person.create') ?></x-Button>
<?= $this->endSection() ?> <?= $this->endSection() ?>
<?= $this->section('content') ?> <?= $this->section('content') ?>
@ -18,12 +18,12 @@
<form action="<?= route_to('episode-persons-manage', $podcast->id, $episode->id) ?>" method="POST" class="max-w-xl"> <form action="<?= route_to('episode-persons-manage', $podcast->id, $episode->id) ?>" method="POST" class="max-w-xl">
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Section <x-Forms.Section
title="<?= lang('Person.episode_form.add_section_title') ?>" title="<?= lang('Person.episode_form.add_section_title') ?>"
subtitle="<?= lang('Person.episode_form.add_section_subtitle') ?>" subtitle="<?= lang('Person.episode_form.add_section_subtitle') ?>"
> >
<Forms.Field <x-Forms.Field
as="MultiSelect" as="MultiSelect"
id="persons" id="persons"
name="persons[]" name="persons[]"
@ -31,10 +31,10 @@
hint="<?= esc(lang('Person.episode_form.persons_hint')) ?>" hint="<?= esc(lang('Person.episode_form.persons_hint')) ?>"
options="<?= esc(json_encode($personOptions)) ?>" options="<?= esc(json_encode($personOptions)) ?>"
selected="<?= esc(json_encode(old('persons', []))) ?>" selected="<?= esc(json_encode(old('persons', []))) ?>"
required="true" isRequired="true"
/> />
<Forms.Field <x-Forms.Field
as="MultiSelect" as="MultiSelect"
id="roles" id="roles"
name="roles[]" name="roles[]"
@ -44,9 +44,9 @@
selected="<?= esc(json_encode(old('roles', []))) ?>" selected="<?= esc(json_encode(old('roles', []))) ?>"
/> />
<Button variant="primary" type="submit" class="self-end"><?= lang('Person.episode_form.submit_add') ?></Button> <x-Button variant="primary" type="submit" class="self-end"><?= lang('Person.episode_form.submit_add') ?></x-Button>
</Forms.Section> </x-Forms.Section>
</form> </form>
@ -87,7 +87,7 @@
'header' => lang('Common.actions'), 'header' => lang('Common.actions'),
'cell' => function ($person): string { 'cell' => function ($person): string {
// @icon('delete-bin-fill') // @icon('delete-bin-fill')
return '<Button uri="' . route_to('episode-person-remove', $person->podcast_id, $person->episode_id, $person->id) . '" variant="danger" size="small" iconLeft="delete-bin-fill">' . lang('Person.episode_form.remove') . '</Button>'; return '<x-Button uri="' . route_to('episode-person-remove', $person->podcast_id, $person->episode_id, $person->id) . '" variant="danger" size="small" iconLeft="delete-bin-fill">' . lang('Person.episode_form.remove') . '</x-Button>';
}, },
], ],
], ],

View File

@ -16,7 +16,7 @@
'class' => 'mr-2 text-lg', 'class' => 'mr-2 text-lg',
]) . lang('Episode.publish_form.back_to_episode_dashboard'), ]) . lang('Episode.publish_form.back_to_episode_dashboard'),
[ [
'class' => 'inline-flex items-center font-semibold mr-4 text-sm focus:ring-accent', 'class' => 'inline-flex items-center font-semibold mr-4 text-sm',
], ],
) ?> ) ?>
@ -39,7 +39,7 @@
</div> </div>
</div> </div>
<div class="px-4 mb-2"> <div class="px-4 mb-2">
<Forms.Textarea name="message" placeholder="<?= lang('Episode.publish_form.message_placeholder') ?>" autofocus="" rows="2" /> <x-Forms.Textarea name="message" placeholder="<?= lang('Episode.publish_form.message_placeholder') ?>" autofocus="" rows="2" />
</div> </div>
<div class="flex border-y"> <div class="flex border-y">
<img src="<?= $episode->cover <img src="<?= $episode->cover
@ -82,14 +82,14 @@
<legend class="text-lg font-semibold"><?= lang( <legend class="text-lg font-semibold"><?= lang(
'Episode.publish_form.publication_date', 'Episode.publish_form.publication_date',
) ?></legend> ) ?></legend>
<Forms.Radio value="now" name="publication_method" isChecked="<?= old('publication_method') ? old('publish') === 'now' : true ?>"><?= lang('Episode.publish_form.publication_method.now') ?></Forms.Radio> <x-Forms.Radio value="now" name="publication_method" isChecked="<?= old('publication_method') ? old('publish') === 'now' : true ?>"><?= lang('Episode.publish_form.publication_method.now') ?></x-Forms.Radio>
<div class="inline-flex flex-wrap items-center radio-toggler"> <div class="inline-flex flex-wrap items-center radio-toggler">
<input <input
class="w-6 h-6 border-contrast text-accent-base border-3 focus:ring-accent" class="w-6 h-6 border-contrast text-accent-base border-3"
type="radio" id="schedule" name="publication_method" value="schedule" <?= old('publication_method') && old('publication_method') === 'schedule' ? 'checked' : '' ?> /> type="radio" id="schedule" name="publication_method" value="schedule" <?= old('publication_method') && old('publication_method') === 'schedule' ? 'checked' : '' ?> />
<Label for="schedule" class="pl-2 leading-8"><?= lang('Episode.publish_form.publication_method.schedule') ?></label> <x-Label for="schedule" class="pl-2 leading-8"><?= lang('Episode.publish_form.publication_method.schedule') ?></label>
<div class="w-full mt-2 radio-toggler-element"> <div class="w-full mt-2 radio-toggler-element">
<Forms.Field <x-Forms.Field
as="DatetimePicker" as="DatetimePicker"
name="scheduled_publication_date" name="scheduled_publication_date"
label="<?= esc(lang('Episode.publish_form.scheduled_publication_date')) ?>" label="<?= esc(lang('Episode.publish_form.scheduled_publication_date')) ?>"
@ -101,11 +101,11 @@
</fieldset> </fieldset>
<?php endif ?> <?php endif ?>
<Alert id="publish-warning" variant="warning" class="hidden mt-2" title="<?= lang('Episode.publish_form.message_warning') ?>"><?= lang('Episode.publish_form.message_warning_hint') ?></Alert> <x-Alert id="publish-warning" variant="warning" class="hidden mt-2" title="<?= lang('Episode.publish_form.message_warning') ?>"><?= lang('Episode.publish_form.message_warning_hint') ?></x-Alert>
<div class="flex items-center justify-between w-full mt-4"> <div class="flex items-center justify-between w-full mt-4">
<Button uri="<?= route_to('episode-publish-cancel', $podcast->id, $episode->id) ?>" variant="danger"><?= lang('Episode.publish_form.cancel_publication') ?></Button> <x-Button uri="<?= route_to('episode-publish-cancel', $podcast->id, $episode->id) ?>" variant="danger"><?= lang('Episode.publish_form.cancel_publication') ?></x-Button>
<Button variant="primary" type="submit" data-btn-text-warning="<?= lang('Episode.publish_form.message_warning_submit') ?>" data-btn-text="<?= lang('Episode.publish_form.submit') ?>"><?= lang('Episode.publish_form.submit') ?></Button> <x-Button variant="primary" type="submit" data-btn-text-warning="<?= lang('Episode.publish_form.message_warning_submit') ?>" data-btn-text="<?= lang('Episode.publish_form.submit') ?>"><?= lang('Episode.publish_form.submit') ?></x-Button>
</div> </div>
</form> </form>

View File

@ -24,16 +24,16 @@
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="client_timezone" value="UTC" /> <input type="hidden" name="client_timezone" value="UTC" />
<Forms.Field <x-Forms.Field
as="DatetimePicker" as="DatetimePicker"
name="new_publication_date" name="new_publication_date"
label="<?= esc(lang('Episode.publish_date_edit_form.new_publication_date')) ?>" label="<?= esc(lang('Episode.publish_date_edit_form.new_publication_date')) ?>"
hint="<?= esc(lang('Episode.publish_date_edit_form.new_publication_date_hint')) ?>" hint="<?= esc(lang('Episode.publish_date_edit_form.new_publication_date_hint')) ?>"
value="<?= $episode->published_at ?>" value="<?= $episode->published_at ?>"
required="true" isRequired="true"
/> />
<Button variant="primary" type="submit" class="mt-4"><?= lang('Episode.publish_date_edit_form.submit') ?></Button> <x-Button variant="primary" type="submit" class="mt-4"><?= lang('Episode.publish_date_edit_form.submit') ?></x-Button>
</form> </form>

View File

@ -41,7 +41,7 @@
</div> </div>
</div> </div>
<div class="px-4 mb-2"> <div class="px-4 mb-2">
<Forms.Textarea name="message" placeholder="<?= lang('Episode.publish_form.message_placeholder') ?>" autofocus="" value="<?= esc($post->message) ?>" rows="2" /> <x-Forms.Textarea name="message" placeholder="<?= lang('Episode.publish_form.message_placeholder') ?>" autofocus="" value="<?= esc($post->message) ?>" rows="2" />
</div> </div>
<div class="flex border-y"> <div class="flex border-y">
<img src="<?= $episode->cover <img src="<?= $episode->cover
@ -86,14 +86,14 @@
<legend class="text-lg font-semibold"><?= lang( <legend class="text-lg font-semibold"><?= lang(
'Episode.publish_form.publication_date', 'Episode.publish_form.publication_date',
) ?></legend> ) ?></legend>
<Forms.Radio value="now" name="publication_method" isChecked="<?= old('publication_method') && old('publish') === 'now' ?>"><?= lang('Episode.publish_form.publication_method.now') ?></Forms.Radio> <x-Forms.Radio value="now" name="publication_method" isChecked="<?= old('publication_method') && old('publish') === 'now' ?>"><?= lang('Episode.publish_form.publication_method.now') ?></x-Forms.Radio>
<div class="inline-flex flex-wrap items-center radio-toggler"> <div class="inline-flex flex-wrap items-center radio-toggler">
<input <input
class="w-6 h-6 border-contrast text-accent-base border-3 focus:ring-accent" class="w-6 h-6 border-contrast text-accent-base border-3"
type="radio" id="schedule" name="publication_method" value="schedule" <?= old('publication_method') ? old('publication_method') === 'schedule' : 'checked' ?> /> type="radio" id="schedule" name="publication_method" value="schedule" <?= old('publication_method') ? old('publication_method') === 'schedule' : 'checked' ?> />
<Label for="schedule" class="pl-2 leading-8"><?= lang('Episode.publish_form.publication_method.schedule') ?></label> <x-Label for="schedule" class="pl-2 leading-8"><?= lang('Episode.publish_form.publication_method.schedule') ?></label>
<div class="w-full mt-2 radio-toggler-element"> <div class="w-full mt-2 radio-toggler-element">
<Forms.Field <x-Forms.Field
as="DatetimePicker" as="DatetimePicker"
name="scheduled_publication_date" name="scheduled_publication_date"
label="<?= esc(lang('Episode.publish_form.scheduled_publication_date')) ?>" label="<?= esc(lang('Episode.publish_form.scheduled_publication_date')) ?>"
@ -105,11 +105,11 @@
</fieldset> </fieldset>
<?php endif ?> <?php endif ?>
<Alert id="publish-warning" variant="warning" class="hidden mt-2" title="<?= lang('Episode.publish_form.message_warning') ?>"><?= lang('Episode.publish_form.message_warning_hint') ?></Alert> <x-Alert id="publish-warning" variant="warning" class="hidden mt-2" title="<?= lang('Episode.publish_form.message_warning') ?>"><?= lang('Episode.publish_form.message_warning_hint') ?></x-Alert>
<div class="flex items-center justify-between w-full mt-4"> <div class="flex items-center justify-between w-full mt-4">
<Button uri="<?= route_to('episode-publish-cancel', $podcast->id, $episode->id) ?>" variant="danger"><?= lang('Episode.publish_form.cancel_publication') ?></Button> <x-Button uri="<?= route_to('episode-publish-cancel', $podcast->id, $episode->id) ?>" variant="danger"><?= lang('Episode.publish_form.cancel_publication') ?></x-Button>
<Button variant="primary" type="submit" data-btn-text-warning="<?= lang('Episode.publish_form.message_warning_submit') ?>" data-btn-text="<?= lang('Episode.publish_form.submit_edit') ?>"><?= lang('Episode.publish_form.submit_edit') ?></Button> <x-Button variant="primary" type="submit" data-btn-text-warning="<?= lang('Episode.publish_form.message_warning_submit') ?>" data-btn-text="<?= lang('Episode.publish_form.submit_edit') ?>"><?= lang('Episode.publish_form.submit_edit') ?></x-Button>
</div> </div>
</form> </form>

View File

@ -10,7 +10,7 @@
<?= $this->section('headerRight') ?> <?= $this->section('headerRight') ?>
<?php // @icon('add-fill')?> <?php // @icon('add-fill')?>
<Button uri="<?= route_to('soundbites-create', $podcast->id, $episode->id) ?>" variant="primary" iconLeft="add-fill"><?= lang('Soundbite.create') ?></Button> <x-Button uri="<?= route_to('soundbites-create', $podcast->id, $episode->id) ?>" variant="primary" iconLeft="add-fill"><?= lang('Soundbite.create') ?></x-Button>
<?= $this->endSection() ?> <?= $this->endSection() ?>
<?= $this->section('content') ?> <?= $this->section('content') ?>
@ -26,10 +26,10 @@
[ [
'header' => lang('Common.actions'), 'header' => lang('Common.actions'),
'cell' => function ($soundbite): string { 'cell' => function ($soundbite): string {
return '<button id="more-dropdown-' . $soundbite->id . '" type="button" class="inline-flex items-center p-1 rounded-full focus:ring-accent" data-dropdown="button" data-dropdown-target="more-dropdown-' . $soundbite->id . '-menu" aria-haspopup="true" aria-expanded="false">' . return '<button id="more-dropdown-' . $soundbite->id . '" type="button" class="inline-flex items-center p-1 rounded-full" data-dropdown="button" data-dropdown-target="more-dropdown-' . $soundbite->id . '-menu" aria-haspopup="true" aria-expanded="false">' .
icon('more-2-fill') . icon('more-2-fill') .
'</button>' . '</button>' .
'<DropdownMenu id="more-dropdown-' . $soundbite->id . '-menu" labelledby="more-dropdown-' . $soundbite->id . '" offsetY="-24" items="' . esc(json_encode([ '<x-DropdownMenu id="more-dropdown-' . $soundbite->id . '-menu" labelledby="more-dropdown-' . $soundbite->id . '" offsetY="-24" items="' . esc(json_encode([
[ [
'type' => 'link', 'type' => 'link',
'title' => lang('Soundbite.delete'), 'title' => lang('Soundbite.delete'),

View File

@ -14,10 +14,10 @@
<form id="soundbites-form" action="<?= route_to('episode-soundbites-edit', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col"> <form id="soundbites-form" action="<?= route_to('episode-soundbites-edit', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col">
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Field <x-Forms.Field
name="title" name="title"
label="<?= esc(lang('Soundbite.form.soundbite_title')) ?>" label="<?= esc(lang('Soundbite.form.soundbite_title')) ?>"
required="true" isRequired="true"
class="max-w-sm" class="max-w-sm"
/> />
<audio-clipper start-time="<?= old('start_time', 0) ?>" audio-duration="<?= $episode->audio->duration ?>" duration="<?= old('duration', $episode->audio->duration >= 60 ? 60 : $episode->audio->duration) ?>" min-duration="10" volume=".5" height="50" trim-start-label="<?= lang('VideoClip.form.trim_start') ?>" trim-end-label="<?= lang('VideoClip.form.trim_end') ?>" class="mt-8"> <audio-clipper start-time="<?= old('start_time', 0) ?>" audio-duration="<?= $episode->audio->duration ?>" duration="<?= old('duration', $episode->audio->duration >= 60 ? 60 : $episode->audio->duration) ?>" min-duration="10" volume=".5" height="50" trim-start-label="<?= lang('VideoClip.form.trim_start') ?>" trim-end-label="<?= lang('VideoClip.form.trim_end') ?>" class="mt-8">
@ -29,7 +29,7 @@
</audio-clipper> </audio-clipper>
<?php // @icon('arrow-right-fill')?> <?php // @icon('arrow-right-fill')?>
<Button variant="primary" type="submit" class="self-end mt-4" iconRight="arrow-right-fill"><?= lang('Soundbite.form.submit') ?></Button> <x-Button variant="primary" type="submit" class="self-end mt-4" iconRight="arrow-right-fill"><?= lang('Soundbite.form.submit') ?></x-Button>
</form> </form>

View File

@ -13,13 +13,13 @@
<form action="<?= route_to('episode-unpublish', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col max-w-lg mx-auto"> <form action="<?= route_to('episode-unpublish', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col max-w-lg mx-auto">
<?= csrf_field() ?> <?= csrf_field() ?>
<Alert variant="danger" class="font-semibold"><?= lang('Episode.unpublish_form.disclaimer') ?></Alert> <x-Alert variant="danger" class="font-semibold"><?= lang('Episode.unpublish_form.disclaimer') ?></x-Alert>
<Forms.Checkbox class="mt-2" name="understand" required="true" isChecked="false"><?= lang('Episode.unpublish_form.understand') ?></Forms.Checkbox> <x-Forms.Checkbox class="mt-2" name="understand" isRequired="true" isChecked="false"><?= lang('Episode.unpublish_form.understand') ?></x-Forms.Checkbox>
<div class="self-end mt-4"> <div class="self-end mt-4">
<Button uri="<?= route_to('episode-view', $podcast->id, $episode->id) ?>"><?= lang('Common.cancel') ?></Button> <x-Button uri="<?= route_to('episode-view', $podcast->id, $episode->id) ?>"><?= lang('Common.cancel') ?></x-Button>
<Button type="submit" variant="danger"><?= lang('Episode.unpublish_form.submit') ?></Button> <x-Button type="submit" variant="danger"><?= lang('Episode.unpublish_form.submit') ?></x-Button>
</div> </div>
</form> </form>

View File

@ -16,7 +16,7 @@ use CodeIgniter\I18n\Time;
<?= $this->section('headerRight') ?> <?= $this->section('headerRight') ?>
<?php // @icon('add-fill')?> <?php // @icon('add-fill')?>
<Button uri="<?= route_to('video-clips-create', $podcast->id, $episode->id) ?>" variant="primary" iconLeft="add-fill"><?= lang('VideoClip.create') ?></Button> <x-Button uri="<?= route_to('video-clips-create', $podcast->id, $episode->id) ?>" variant="primary" iconLeft="add-fill"><?= lang('VideoClip.create') ?></x-Button>
<?= $this->endSection() ?> <?= $this->endSection() ?>
<?= $this->section('content') ?> <?= $this->section('content') ?>
@ -52,7 +52,7 @@ use CodeIgniter\I18n\Time;
'passed' => '', 'passed' => '',
]; ];
return '<Pill variant="' . $pillVariantMap[$videoClip->status] . '" icon="' . $pillIconMap[$videoClip->status] . '" iconClass="' . $pillIconClassMap[$videoClip->status] . '" hint="' . lang('VideoClip.list.status.' . $videoClip->status . '_hint') . '">' . lang('VideoClip.list.status.' . $videoClip->status) . '</Pill>'; return '<x-Pill variant="' . $pillVariantMap[$videoClip->status] . '" icon="' . $pillIconMap[$videoClip->status] . '" iconClass="' . $pillIconClassMap[$videoClip->status] . '" hint="' . lang('VideoClip.list.status.' . $videoClip->status . '_hint') . '">' . lang('VideoClip.list.status.' . $videoClip->status) . '</x-Pill>';
}, },
], ],
[ [
@ -63,7 +63,7 @@ use CodeIgniter\I18n\Time;
'portrait' => 'aspect-[9/16]', 'portrait' => 'aspect-[9/16]',
'squared' => 'aspect-square', 'squared' => 'aspect-square',
]; ];
return '<a href="' . route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id) . '" class="inline-flex items-center w-full group gap-x-2 focus:ring-accent"><div class="relative"><span class="absolute block w-3 h-3 rounded-full ring-2 ring-white -bottom-1 -left-1" data-tooltip="bottom" title="' . lang('Settings.theme.' . $videoClip->theme['name']) . '" style="background-color:hsl(' . $videoClip->theme['preview'] . ')"></span><div class="flex items-center justify-center h-6 overflow-hidden bg-black rounded-sm aspect-video" data-tooltip="bottom" title="' . lang('VideoClip.format.' . $videoClip->format) . '"><span class="flex items-center justify-center h-full text-white bg-gray-400 ' . $formatClass[$videoClip->format] . '">' . icon('play-fill') . '</span></div></div><div class="flex flex-col"><div class="text-sm">#' . $videoClip->id . ' <span class="font-semibold group-hover:underline">' . esc($videoClip->title) . '</span><span class="ml-1 text-sm">by ' . esc($videoClip->user->username) . '</span></div><span class="text-xs">' . format_duration((int) $videoClip->duration) . '</span></div></a>'; return '<a href="' . route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id) . '" class="inline-flex items-center w-full group gap-x-2"><div class="relative"><span class="absolute block w-3 h-3 rounded-full ring-2 ring-white -bottom-1 -left-1" data-tooltip="bottom" title="' . lang('Settings.theme.' . $videoClip->theme['name']) . '" style="background-color:hsl(' . $videoClip->theme['preview'] . ')"></span><div class="flex items-center justify-center h-6 overflow-hidden bg-black rounded-sm aspect-video" data-tooltip="bottom" title="' . lang('VideoClip.format.' . $videoClip->format) . '"><span class="flex items-center justify-center h-full text-white bg-gray-400 ' . $formatClass[$videoClip->format] . '">' . icon('play-fill') . '</span></div></div><div class="flex flex-col"><div class="text-sm">#' . $videoClip->id . ' <span class="font-semibold group-hover:underline">' . esc($videoClip->title) . '</span><span class="ml-1 text-sm">by ' . esc($videoClip->user->username) . '</span></div><span class="text-xs">' . format_duration((int) $videoClip->duration) . '</span></div></a>';
}, },
], ],
[ [
@ -98,14 +98,14 @@ use CodeIgniter\I18n\Time;
helper('misc'); helper('misc');
$filename = 'clip-' . slugify($videoClip->title) . "-{$videoClip->start_time}-{$videoClip->end_time}"; $filename = 'clip-' . slugify($videoClip->title) . "-{$videoClip->start_time}-{$videoClip->end_time}";
// @icon('import-fill') // @icon('import-fill')
$downloadButton = '<IconButton glyph="import-fill" uri="' . $videoClip->media->file_url . '" download="' . $filename . '">' . lang('VideoClip.download_clip') . '</IconButton>'; $downloadButton = '<x-IconButton glyph="import-fill" uri="' . $videoClip->media->file_url . '" download="' . $filename . '">' . lang('VideoClip.download_clip') . '</x-IconButton>';
} }
return '<div class="inline-flex items-center gap-x-2">' . $downloadButton . return '<div class="inline-flex items-center gap-x-2">' . $downloadButton .
'<button id="more-dropdown-' . $videoClip->id . '" type="button" class="inline-flex items-center p-1 rounded-full focus:ring-accent" data-dropdown="button" data-dropdown-target="more-dropdown-' . $videoClip->id . '-menu" aria-haspopup="true" aria-expanded="false">' . '<button id="more-dropdown-' . $videoClip->id . '" type="button" class="inline-flex items-center p-1 rounded-full" data-dropdown="button" data-dropdown-target="more-dropdown-' . $videoClip->id . '-menu" aria-haspopup="true" aria-expanded="false">' .
icon('more-2-fill') . icon('more-2-fill') .
'</button>' . '</button>' .
'<DropdownMenu id="more-dropdown-' . $videoClip->id . '-menu" labelledby="more-dropdown-' . $videoClip->id . '" offsetY="-24" items="' . esc(json_encode([ '<x-DropdownMenu id="more-dropdown-' . $videoClip->id . '-menu" labelledby="more-dropdown-' . $videoClip->id . '" offsetY="-24" items="' . esc(json_encode([
[ [
'type' => 'link', 'type' => 'link',
'title' => lang('VideoClip.go_to_page'), 'title' => lang('VideoClip.go_to_page'),

View File

@ -31,48 +31,48 @@
</div> </div>
<div class="flex flex-col items-end w-full max-w-xl xl:max-w-sm 2xl:max-w-xl gap-y-4"> <div class="flex flex-col items-end w-full max-w-xl xl:max-w-sm 2xl:max-w-xl gap-y-4">
<Forms.Section title="<?= lang('VideoClip.form.params_section_title') ?>" > <x-Forms.Section title="<?= lang('VideoClip.form.params_section_title') ?>" >
<Forms.Field <x-Forms.Field
name="title" name="title"
label="<?= esc(lang('VideoClip.form.clip_title')) ?>" label="<?= esc(lang('VideoClip.form.clip_title')) ?>"
required="true" isRequired="true"
/> />
<fieldset class="flex flex-wrap gap-x-1 gap-y-2"> <fieldset class="flex flex-wrap gap-x-1 gap-y-2">
<legend><?= lang('VideoClip.form.format.label') ?></legend> <legend><?= lang('VideoClip.form.format.label') ?></legend>
<Forms.RadioButton <x-Forms.RadioButton
value="landscape" value="landscape"
name="format" name="format"
isChecked="true" isChecked="true"
required="true" isRequired="true"
hint="<?= esc(lang('VideoClip.form.format.landscape_hint')) ?>"><?= lang('VideoClip.format.landscape') ?></Forms.RadioButton> hint="<?= esc(lang('VideoClip.form.format.landscape_hint')) ?>"><?= lang('VideoClip.format.landscape') ?></x-Forms.RadioButton>
<Forms.RadioButton <x-Forms.RadioButton
value="portrait" value="portrait"
name="format" name="format"
required="true" isRequired="true"
hint="<?= esc(lang('VideoClip.form.format.portrait_hint')) ?>"><?= lang('VideoClip.format.portrait') ?></Forms.RadioButton> hint="<?= esc(lang('VideoClip.form.format.portrait_hint')) ?>"><?= lang('VideoClip.format.portrait') ?></x-Forms.RadioButton>
<Forms.RadioButton <x-Forms.RadioButton
value="squared" value="squared"
name="format" name="format"
required="true" isRequired="true"
hint="<?= esc(lang('VideoClip.form.format.squared_hint')) ?>"><?= lang('VideoClip.format.squared') ?></Forms.RadioButton> hint="<?= esc(lang('VideoClip.form.format.squared_hint')) ?>"><?= lang('VideoClip.format.squared') ?></x-Forms.RadioButton>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend><?= lang('VideoClip.form.theme') ?></legend> <legend><?= lang('VideoClip.form.theme') ?></legend>
<div class="grid gap-x-4 gap-y-2 grid-cols-colorButtons"> <div class="grid gap-x-4 gap-y-2 grid-cols-colorButtons">
<?php foreach (config('MediaClipper')->themes as $themeName => $colors): ?> <?php foreach (config('MediaClipper')->themes as $themeName => $colors): ?>
<Forms.ColorRadioButton <x-Forms.ColorRadioButton
class="mx-auto" class="mx-auto"
value="<?= esc($themeName) ?>" value="<?= esc($themeName) ?>"
name="theme" name="theme"
required="true" isRequired="true"
isChecked="<?= $themeName === 'pine' ? 'true' : 'false' ?>" isChecked="<?= $themeName === 'pine' ? 'true' : 'false' ?>"
style="--color-accent-base: <?= $colors['preview']?>; --color-background-preview: <?= $colors['preview-background'] ?>"><?= lang('Settings.theme.' . $themeName) ?></Forms.ColorRadioButton> style="--color-accent-base: <?= $colors['preview']?>; --color-background-preview: <?= $colors['preview-background'] ?>"><?= lang('Settings.theme.' . $themeName) ?></x-Forms.ColorRadioButton>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</fieldset> </fieldset>
</Forms.Section> </x-Forms.Section>
<?php // @icon('arrow-right-fill')?> <?php // @icon('arrow-right-fill')?>
<Button variant="primary" type="submit" iconRight="arrow-right-fill" class="self-end"><?= lang('VideoClip.form.submit') ?></Button> <x-Button variant="primary" type="submit" iconRight="arrow-right-fill" class="self-end"><?= lang('VideoClip.form.submit') ?></x-Button>
</div> </div>
</form> </form>

View File

@ -12,9 +12,9 @@
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="flex flex-col items-start"> <div class="flex flex-col items-start">
<Heading class="flex items-center gap-x-2"><?= icon('alert-fill', [ <x-Heading class="flex items-center gap-x-2"><?= icon('alert-fill', [
'class' => 'flex-shrink-0 text-xl text-orange-600', 'class' => 'flex-shrink-0 text-xl text-orange-600',
]) ?><?= lang('VideoClip.requirements.title') ?></Heading> ]) ?><?= lang('VideoClip.requirements.title') ?></x-Heading>
<p class="max-w-sm font-semibold text-gray-500"><?= lang('VideoClip.requirements.missing') ?></p> <p class="max-w-sm font-semibold text-gray-500"><?= lang('VideoClip.requirements.missing') ?></p>
<div class="flex flex-col mt-4"> <div class="flex flex-col mt-4">
<?php foreach ($checks as $requirement => $value): ?> <?php foreach ($checks as $requirement => $value): ?>

View File

@ -12,19 +12,19 @@
<?= publication_pill( <?= publication_pill(
$episode->published_at, $episode->published_at,
$episode->publication_status, $episode->publication_status,
'text-sm ml-2 align-middle', 'text-sm align-middle',
) ?> ) ?>
<?= $this->endSection() ?> <?= $this->endSection() ?>
<?= $this->section('headerRight') ?> <?= $this->section('headerRight') ?>
<?php if ($episode->publication_status === 'published'): ?> <?php if ($episode->publication_status === 'published'): ?>
<?php // @icon('history-fill')?> <?php // @icon('history-fill')?>
<IconButton <x-IconButton
uri="<?= route_to('episode-publish_date_edit', $podcast->id, $episode->id) ?>" uri="<?= route_to('episode-publish_date_edit', $podcast->id, $episode->id) ?>"
glyph="history-fill" glyph="history-fill"
variant="secondary" variant="secondary"
glyphClass="text-xl" glyphClass="text-xl"
><?= lang('Episode.publish_date_edit') ?></IconButton> ><?= lang('Episode.publish_date_edit') ?></x-IconButton>
<?php endif; ?> <?php endif; ?>
<?= publication_button( <?= publication_button(
$podcast->id, $podcast->id,
@ -41,7 +41,7 @@
</div> </div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Charts.XY title="<?= lang('Charts.episode_by_day') ?>" dataUrl="<?= route_to( <x-Charts.XY title="<?= lang('Charts.episode_by_day') ?>" dataUrl="<?= route_to(
'analytics-filtered-data', 'analytics-filtered-data',
$podcast->id, $podcast->id,
'PodcastByEpisode', 'PodcastByEpisode',
@ -49,7 +49,7 @@
$episode->id, $episode->id,
) ?>"/> ) ?>"/>
<Charts.XY title="<?= lang('Charts.episode_by_month') ?>" dataUrl="<?= route_to( <x-Charts.XY title="<?= lang('Charts.episode_by_month') ?>" dataUrl="<?= route_to(
'analytics-filtered-data', 'analytics-filtered-data',
$podcast->id, $podcast->id,
'PodcastByEpisode', 'PodcastByEpisode',

View File

@ -14,12 +14,12 @@
<form action="<?= route_to('fediverse-attempt-block-actor') ?>" method="POST" class="flex flex-col max-w-md"> <form action="<?= route_to('fediverse-attempt-block-actor') ?>" method="POST" class="flex flex-col max-w-md">
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Field <x-Forms.Field
name="handle" name="handle"
label="<?= esc(lang('Fediverse.block_lists_form.handle')) ?>" label="<?= esc(lang('Fediverse.block_lists_form.handle')) ?>"
hint="<?= esc(lang('Fediverse.block_lists_form.handle_hint')) ?>" hint="<?= esc(lang('Fediverse.block_lists_form.handle_hint')) ?>"
required="true" /> isRequired="true" />
<Button variant="primary" type="submit" class="self-end"><?= lang('Fediverse.block_lists_form.submit') ?></Button> <x-Button variant="primary" type="submit" class="self-end"><?= lang('Fediverse.block_lists_form.submit') ?></x-Button>
</form> </form>
<?= data_table( <?= data_table(
@ -40,7 +40,7 @@
$blockedActor->id . $blockedActor->id .
'" />' . '" />' .
csrf_field() . csrf_field() .
'<Button uri="' . route_to('fediverse-unblock-actor', esc($blockedActor->username)) . '" variant="info" size="small" type="submit">' . lang('Fediverse.list.unblock') . '</Button>' . '<x-Button uri="' . route_to('fediverse-unblock-actor', esc($blockedActor->username)) . '" variant="info" size="small" type="submit">' . lang('Fediverse.list.unblock') . '</x-Button>' .
'</form>'; '</form>';
}, },
], ],

View File

@ -14,11 +14,11 @@
<form action="<?= route_to('fediverse-attempt-block-domain') ?>" method="POST" class="flex flex-col max-w-md"> <form action="<?= route_to('fediverse-attempt-block-domain') ?>" method="POST" class="flex flex-col max-w-md">
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Field <x-Forms.Field
name="domain" name="domain"
label="<?= esc(lang('Fediverse.block_lists_form.domain')) ?>" label="<?= esc(lang('Fediverse.block_lists_form.domain')) ?>"
required="true" /> isRequired="true" />
<Button variant="primary" type="submit" class="self-end"><?= lang('Fediverse.block_lists_form.submit') ?></Button> <x-Button variant="primary" type="submit" class="self-end"><?= lang('Fediverse.block_lists_form.submit') ?></x-Button>
</form> </form>
<?= data_table( <?= data_table(
@ -39,7 +39,7 @@
esc($blockedDomain->name) . esc($blockedDomain->name) .
'" />' . '" />' .
csrf_field() . csrf_field() .
'<Button uri="' . route_to('fediverse-unblock-domain', esc($blockedDomain->name)) . '" variant="info" size="small" type="submit">' . lang('Fediverse.list.unblock') . '</Button>' . '<x-Button uri="' . route_to('fediverse-unblock-domain', esc($blockedDomain->name)) . '" variant="info" size="small" type="submit">' . lang('Fediverse.list.unblock') . '</x-Button>' .
'</form>'; '</form>';
}, },
], ],

View File

@ -38,9 +38,9 @@ use Modules\PodcastImport\Entities\TaskStatus;
'passed' => '', 'passed' => '',
]; ];
$errorHint = $importTask->status === TaskStatus::Failed ? hint_tooltip(esc($importTask->error), 'ml-1') : ''; $errorHint = $importTask->status === TaskStatus::Failed ? '<x-Hint class="ml-1">' . esc($importTask->error) . '</x-Hint>' : '';
return '<div class="flex items-center"><Pill variant="' . $pillVariantMap[$importTask->status->value] . '" icon="' . $pillIconMap[$importTask->status->value] . '" iconClass="' . $pillIconClassMap[$importTask->status->value] . '" hint="' . lang('PodcastImport.queue.status.' . $importTask->status->value . '_hint') . '">' . lang('PodcastImport.queue.status.' . $importTask->status->value) . '</Pill>' . $errorHint . '</div>'; return '<div class="flex items-center"><x-Pill variant="' . $pillVariantMap[$importTask->status->value] . '" icon="' . $pillIconMap[$importTask->status->value] . '" iconClass="' . $pillIconClassMap[$importTask->status->value] . '" hint="' . lang('PodcastImport.queue.status.' . $importTask->status->value . '_hint') . '">' . lang('PodcastImport.queue.status.' . $importTask->status->value) . '</x-Pill>' . $errorHint . '</div>';
}, },
], ],
[ [
@ -86,10 +86,10 @@ use Modules\PodcastImport\Entities\TaskStatus;
'cell' => function (PodcastImportTask $importTask) { 'cell' => function (PodcastImportTask $importTask) {
if ($importTask->episodes_count) { if ($importTask->episodes_count) {
$progressPercentage = (int) ($importTask->getProgress() * 100) . '%'; $progressPercentage = (int) ($importTask->getProgress() * 100) . '%';
$moreInfoHelper = hint_tooltip(lang('PodcastImport.queue.imported_episodes_hint', [ $moreInfoHelper = '<x-Hint class="ml-1">' . lang('PodcastImport.queue.imported_episodes_hint', [
'newlyImportedCount' => $importTask->episodes_newly_imported, 'newlyImportedCount' => $importTask->episodes_newly_imported,
'alreadyImportedCount' => $importTask->episodes_already_imported, 'alreadyImportedCount' => $importTask->episodes_already_imported,
]), 'ml-1'); ]) . '</x-Hint>';
return <<<HTML return <<<HTML
<div class="flex flex-col"> <div class="flex flex-col">
<span>{$progressPercentage}</span> <span>{$progressPercentage}</span>
@ -134,10 +134,10 @@ use Modules\PodcastImport\Entities\TaskStatus;
} }
return '<div class="inline-flex items-center gap-x-2">' . return '<div class="inline-flex items-center gap-x-2">' .
'<button id="more-dropdown-' . $importTask->id . '" type="button" class="inline-flex items-center p-1 rounded-full focus:ring-accent" data-dropdown="button" data-dropdown-target="more-dropdown-' . $importTask->id . '-menu" aria-haspopup="true" aria-expanded="false">' . '<button id="more-dropdown-' . $importTask->id . '" type="button" class="inline-flex items-center p-1 rounded-full" data-dropdown="button" data-dropdown-target="more-dropdown-' . $importTask->id . '-menu" aria-haspopup="true" aria-expanded="false">' .
icon('more-2-fill') . icon('more-2-fill') .
'</button>' . '</button>' .
'<DropdownMenu id="more-dropdown-' . $importTask->id . '-menu" labelledby="more-dropdown-' . $importTask->id . '" offsetY="-24" items="' . esc(json_encode($menuItems)) . '" />' . '<x-DropdownMenu id="more-dropdown-' . $importTask->id . '-menu" labelledby="more-dropdown-' . $importTask->id . '" offsetY="-24" items="' . esc(json_encode($menuItems)) . '" />' .
'</div>'; '</div>';
}, },
], ],

View File

@ -13,51 +13,51 @@
<form action="<?= route_to('import') ?>" method="POST" enctype='multipart/form-data' class="flex flex-col w-full max-w-xl gap-y-8"> <form action="<?= route_to('import') ?>" method="POST" enctype='multipart/form-data' class="flex flex-col w-full max-w-xl gap-y-8">
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Section <x-Forms.Section
title="<?= lang('PodcastImport.old_podcast_section_title') ?>"> title="<?= lang('PodcastImport.old_podcast_section_title') ?>">
<?php // @icon('scales-3-fill')?> <?php // @icon('scales-3-fill')?>
<Alert glyph="scales-3-fill" variant="info" title="<?= lang('PodcastImport.old_podcast_legal_disclaimer_title') ?>"><?= lang('PodcastImport.old_podcast_legal_disclaimer') ?></Alert> <x-Alert glyph="scales-3-fill" variant="info" title="<?= lang('PodcastImport.old_podcast_legal_disclaimer_title') ?>"><?= lang('PodcastImport.old_podcast_legal_disclaimer') ?></x-Alert>
<Forms.Field <x-Forms.Field
name="imported_feed_url" name="imported_feed_url"
label="<?= esc(lang('PodcastImport.imported_feed_url')) ?>" label="<?= esc(lang('PodcastImport.imported_feed_url')) ?>"
hint="<?= esc(lang('PodcastImport.imported_feed_url_hint')) ?>" hint="<?= esc(lang('PodcastImport.imported_feed_url_hint')) ?>"
placeholder="https://…" placeholder="https://…"
type="url" type="url"
required="true" /> isRequired="true" />
</Forms.Section> </x-Forms.Section>
<Forms.Section <x-Forms.Section
title="<?= lang('PodcastImport.new_podcast_section_title') ?>" > title="<?= lang('PodcastImport.new_podcast_section_title') ?>" >
<div class="flex flex-col"> <div class="flex flex-col">
<Forms.Label for="handle" hint="<?= esc(lang('Podcast.form.handle_hint')) ?>"><?= lang('Podcast.form.handle') ?></Forms.Label> <x-Forms.Label for="handle" hint="<?= esc(lang('Podcast.form.handle_hint')) ?>"><?= lang('Podcast.form.handle') ?></x-Forms.Label>
<div class="relative"> <div class="relative">
<?= icon('at-line', [ <?= icon('at-line', [
'class' => 'absolute inset-0 h-full text-xl opacity-40 left-3', 'class' => 'absolute inset-0 h-full text-xl opacity-40 left-3',
]) ?> ]) ?>
<Forms.Input name="handle" class="w-full pl-8" required="true" /> <x-Forms.Input name="handle" class="w-full pl-8" isRequired="true" />
</div> </div>
</div> </div>
<Forms.Field <x-Forms.Field
as="Select" as="Select"
name="language" name="language"
label="<?= esc(lang('Podcast.form.language')) ?>" label="<?= esc(lang('Podcast.form.language')) ?>"
selected="<?= $browserLang ?>" selected="<?= $browserLang ?>"
required="true" isRequired="true"
options="<?= esc(json_encode($languageOptions)) ?>" /> options="<?= esc(json_encode($languageOptions)) ?>" />
<Forms.Field <x-Forms.Field
as="Select" as="Select"
name="category" name="category"
label="<?= esc(lang('Podcast.form.category')) ?>" label="<?= esc(lang('Podcast.form.category')) ?>"
required="true" isRequired="true"
options="<?= esc(json_encode($categoryOptions)) ?>" /> options="<?= esc(json_encode($categoryOptions)) ?>" />
</Forms.Section> </x-Forms.Section>
<Button variant="primary" type="submit" class="self-end"><?= lang('PodcastImport.submit') ?></Button> <x-Button variant="primary" type="submit" class="self-end"><?= lang('PodcastImport.submit') ?></x-Button>
</form> </form>

View File

@ -10,7 +10,7 @@
<?= $this->section('headerRight') ?> <?= $this->section('headerRight') ?>
<?php // @icon('loop-left-fill')?> <?php // @icon('loop-left-fill')?>
<Button uri="<?= route_to('podcast-imports-sync', $podcast->id) ?>" variant="primary" iconLeft="loop-left-fill"><?= lang('PodcastImport.syncForm.title') ?></Button> <x-Button uri="<?= route_to('podcast-imports-sync', $podcast->id) ?>" variant="primary" iconLeft="loop-left-fill"><?= lang('PodcastImport.syncForm.title') ?></x-Button>
<?= $this->endSection() ?> <?= $this->endSection() ?>
<?= $this->section('content') ?> <?= $this->section('content') ?>

View File

@ -11,14 +11,14 @@
<?= $this->section('content') ?> <?= $this->section('content') ?>
<form action="<?= route_to('podcast-imports-sync', $podcast->id) ?>" method="POST" class="flex flex-col max-w-sm gap-y-4" enctype="multipart/form-data"> <form action="<?= route_to('podcast-imports-sync', $podcast->id) ?>" method="POST" class="flex flex-col max-w-sm gap-y-4" enctype="multipart/form-data">
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Field <x-Forms.Field
name="feed_url" name="feed_url"
label="<?= esc(lang('PodcastImport.syncForm.feed_url')) ?>" label="<?= esc(lang('PodcastImport.syncForm.feed_url')) ?>"
hint="<?= esc(lang('PodcastImport.syncForm.feed_url_hint')) ?>" hint="<?= esc(lang('PodcastImport.syncForm.feed_url_hint')) ?>"
required="true" isRequired="true"
value="<?= $podcast->imported_feed_url ?? '' ?>" value="<?= $podcast->imported_feed_url ?? '' ?>"
/> />
<Button variant="primary" class="self-end" type="submit"><?= lang('PodcastImport.syncForm.submit') ?></Button> <x-Button variant="primary" class="self-end" type="submit"><?= lang('PodcastImport.syncForm.submit') ?></x-Button>
</form> </form>
<?= $this->endSection() ?> <?= $this->endSection() ?>

View File

@ -13,7 +13,7 @@
<?= $this->section('headerRight') ?> <?= $this->section('headerRight') ?>
<?php // @icon('add-fill')?> <?php // @icon('add-fill')?>
<Button uri="<?= route_to('podcast-imports-add') ?>" variant="primary" iconLeft="add-fill"><?= lang('Podcast.import') ?></Button> <x-Button uri="<?= route_to('podcast-imports-add') ?>" variant="primary" iconLeft="add-fill"><?= lang('Podcast.import') ?></x-Button>
<?= $this->endSection() ?> <?= $this->endSection() ?>

View File

@ -13,18 +13,18 @@
<form action="<?= route_to('change-password') ?>" method="POST" class="flex flex-col max-w-sm gap-y-4"> <form action="<?= route_to('change-password') ?>" method="POST" class="flex flex-col max-w-sm gap-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Field <x-Forms.Field
name="password" name="password"
label="<?= esc(lang('User.form.password')) ?>" label="<?= esc(lang('User.form.password')) ?>"
required="true" isRequired="true"
type="password" /> type="password" />
<Forms.Field <x-Forms.Field
name="new_password" name="new_password"
label="<?= esc(lang('User.form.new_password')) ?>" label="<?= esc(lang('User.form.new_password')) ?>"
required="true" isRequired="true"
type="password" type="password"
autocomplete="new-password" /> autocomplete="new-password" />
<Button variant="primary" class="self-end" type="submit"><?= lang('User.form.submit_password_change') ?></Button> <x-Button variant="primary" class="self-end" type="submit"><?= lang('User.form.submit_password_change') ?></x-Button>
</form> </form>
<?= $this->endSection() ?> <?= $this->endSection() ?>

View File

@ -14,29 +14,29 @@
<form action="<?= route_to('page-create') ?>" method="POST" class="flex flex-col max-w-3xl gap-y-4"> <form action="<?= route_to('page-create') ?>" method="POST" class="flex flex-col max-w-3xl gap-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Field <x-Forms.Field
name="title" name="title"
label="<?= esc(lang('Page.form.title')) ?>" label="<?= esc(lang('Page.form.title')) ?>"
required="true" isRequired="true"
data-slugify="title" data-slugify="title"
class="max-w-sm" /> class="max-w-sm" />
<div class="flex flex-col max-w-sm"> <div class="flex flex-col max-w-sm">
<Forms.Label for="slug"><?= lang('Page.form.permalink') ?></Forms.Label> <x-Forms.Label for="slug"><?= lang('Page.form.permalink') ?></x-Forms.Label>
<permalink-edit class="inline-flex items-center w-full text-xs" edit-label="<?= lang('Common.edit') ?>" copy-label="<?= lang('Common.copy') ?>" copied-label="<?= lang('Common.copied') ?>" permalink-base="<?= base_url('pages') ?>"> <permalink-edit class="inline-flex items-center w-full text-xs" edit-label="<?= lang('Common.edit') ?>" copy-label="<?= lang('Common.copy') ?>" copied-label="<?= lang('Common.copied') ?>" permalink-base="<?= base_url('pages') ?>">
<span slot="domain" class="flex-shrink-0">/pages/</span> <span slot="domain" class="flex-shrink-0">/pages/</span>
<Forms.Input name="slug" required="true" data-slugify="slug" slot="slug-input" class="flex-1 text-xs" /> <x-Forms.Input name="slug" isRequired="true" data-slugify="slug" slot="slug-input" class="flex-1 text-xs" />
</permalink-edit> </permalink-edit>
</div> </div>
<Forms.Field <x-Forms.Field
as="MarkdownEditor" as="MarkdownEditor"
name="content" name="content"
label="<?= esc(lang('Page.form.content')) ?>" label="<?= esc(lang('Page.form.content')) ?>"
required="true" isRequired="true"
rows="20" /> rows="20" />
<Button variant="primary" type="submit" class="self-end"><?= lang('Page.form.submit_create') ?></Button> <x-Button variant="primary" type="submit" class="self-end"><?= lang('Page.form.submit_create') ?></x-Button>
</form> </form>

View File

@ -14,32 +14,32 @@
<form action="<?= route_to('page-edit', $page->id) ?>" method="POST" class="flex flex-col max-w-3xl gap-y-4"> <form action="<?= route_to('page-edit', $page->id) ?>" method="POST" class="flex flex-col max-w-3xl gap-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Field <x-Forms.Field
name="title" name="title"
label="<?= esc(lang('Page.form.title')) ?>" label="<?= esc(lang('Page.form.title')) ?>"
required="true" isRequired="true"
data-slugify="title" data-slugify="title"
value="<?= esc($page->title) ?>" value="<?= esc($page->title) ?>"
slot="slug-input" slot="slug-input"
class="max-w-sm" /> class="max-w-sm" />
<div class="flex flex-col max-w-sm"> <div class="flex flex-col max-w-sm">
<Forms.Label for="slug"><?= lang('Page.form.permalink') ?></Forms.Label> <x-Forms.Label for="slug"><?= lang('Page.form.permalink') ?></x-Forms.Label>
<permalink-edit class="inline-flex items-center text-xs" edit-label="<?= lang('Common.edit') ?>" copy-label="<?= lang('Common.copy') ?>" copied-label="<?= lang('Common.copied') ?>" permalink-base="<?= base_url('pages') ?>"> <permalink-edit class="inline-flex items-center text-xs" edit-label="<?= lang('Common.edit') ?>" copy-label="<?= lang('Common.copy') ?>" copied-label="<?= lang('Common.copied') ?>" permalink-base="<?= base_url('pages') ?>">
<span slot="domain" class="flex-shrink-0">/pages/<span> <span slot="domain" class="flex-shrink-0">/pages/<span>
<Forms.Input name="slug" value="<?= esc($page->slug) ?>" required="true" data-slugify="slug" slot="slug-input" class="flex-1 text-xs" value="<?= esc($page->slug) ?>"/> <x-Forms.Input name="slug" value="<?= esc($page->slug) ?>" isRequired="true" data-slugify="slug" slot="slug-input" class="flex-1 text-xs" value="<?= esc($page->slug) ?>"/>
</permalink-edit> </permalink-edit>
</div> </div>
<Forms.Field <x-Forms.Field
as="MarkdownEditor" as="MarkdownEditor"
name="content" name="content"
label="<?= esc(lang('Page.form.content')) ?>" label="<?= esc(lang('Page.form.content')) ?>"
value="<?= esc($page->content_markdown) ?>" value="<?= esc($page->content_markdown) ?>"
required="true" isRequired="true"
rows="20" /> rows="20" />
<Button variant="primary" type="submit" class="self-end"><?= lang('Page.form.submit_edit') ?></Button> <x-Button variant="primary" type="submit" class="self-end"><?= lang('Page.form.submit_edit') ?></x-Button>
</form> </form>

View File

@ -10,7 +10,7 @@
<?= $this->section('headerRight') ?> <?= $this->section('headerRight') ?>
<?php // @icon('add-fill')?> <?php // @icon('add-fill')?>
<Button uri="<?= route_to('page-create') ?>" variant="primary" iconLeft="add-fill"><?= lang('Page.create') ?></Button> <x-Button uri="<?= route_to('page-create') ?>" variant="primary" iconLeft="add-fill"><?= lang('Page.create') ?></x-Button>
<?= $this->endSection() ?> <?= $this->endSection() ?>
@ -31,9 +31,9 @@
[ [
'header' => lang('Common.actions'), 'header' => lang('Common.actions'),
'cell' => function ($page) { 'cell' => function ($page) {
return '<Button uri="' . route_to('page', esc($page->slug)) . '" variant="secondary" size="small">' . lang('Page.go_to_page') . '</Button>' . return '<x-Button uri="' . route_to('page', esc($page->slug)) . '" variant="secondary" size="small">' . lang('Page.go_to_page') . '</x-Button>' .
'<Button uri="' . route_to('page-edit', $page->id) . '" variant="info" size="small">' . lang('Page.edit') . '</Button>' . '<x-Button uri="' . route_to('page-edit', $page->id) . '" variant="info" size="small">' . lang('Page.edit') . '</x-Button>' .
'<Button uri="' . route_to('page-delete', $page->id) . '" variant="danger" size="small">' . lang('Page.delete') . '</Button>'; '<x-Button uri="' . route_to('page-delete', $page->id) . '" variant="danger" size="small">' . lang('Page.delete') . '</x-Button>';
}, },
], ],
], ],

View File

@ -10,7 +10,7 @@
<?= $this->section('headerRight') ?> <?= $this->section('headerRight') ?>
<?php // @icon('add-fill')?> <?php // @icon('add-fill')?>
<Button variant="primary" uri="<?= route_to('page-edit', $page->id) ?>" iconLeft="add-fill"><?= lang('Page.edit') ?></Button> <x-Button variant="primary" uri="<?= route_to('page-edit', $page->id) ?>" iconLeft="add-fill"><?= lang('Page.edit') ?></x-Button>
<?= $this->endSection() ?> <?= $this->endSection() ?>
<?= $this->section('content') ?> <?= $this->section('content') ?>

View File

@ -8,8 +8,8 @@
<h2 class="px-4 py-2 font-semibold leading-tight"><?= esc($person->full_name) ?></h2> <h2 class="px-4 py-2 font-semibold leading-tight"><?= esc($person->full_name) ?></h2>
</div> </div>
</a> </a>
<button class="absolute top-0 right-0 z-10 p-2 mt-2 mr-2 text-white transition -translate-y-12 rounded-full opacity-0 focus:ring-accent focus:opacity-100 focus:-translate-y-0 group-hover:translate-y-0 bg-black/50 group-hover:opacity-100" id="more-dropdown-<?= $person->id ?>" data-dropdown="button" data-dropdown-target="more-dropdown-<?= $person->id ?>-menu" aria-haspopup="true" aria-expanded="false" title="<?= lang('Common.more') ?>"><?= icon('more-2-fill') ?></button> <button class="absolute top-0 right-0 z-10 p-2 mt-2 mr-2 text-white transition -translate-y-12 rounded-full opacity-0 focus:opacity-100 focus:-translate-y-0 group-hover:translate-y-0 bg-black/50 group-hover:opacity-100" id="more-dropdown-<?= $person->id ?>" data-dropdown="button" data-dropdown-target="more-dropdown-<?= $person->id ?>-menu" aria-haspopup="true" aria-expanded="false" title="<?= lang('Common.more') ?>"><?= icon('more-2-fill') ?></button>
<DropdownMenu id="more-dropdown-<?= $person->id ?>-menu" labelledby="more-dropdown-<?= $person->id ?>" offsetY="-32" items="<?= esc(json_encode([ <x-DropdownMenu id="more-dropdown-<?= $person->id ?>-menu" labelledby="more-dropdown-<?= $person->id ?>" offsetY="-32" items="<?= esc(json_encode([
[ [
'type' => 'link', 'type' => 'link',
'title' => lang('Person.view'), 'title' => lang('Person.view'),

View File

@ -14,32 +14,32 @@
<form action="<?= route_to('person-create') ?>" method="POST" class="flex flex-col max-w-sm gap-y-4" enctype="multipart/form-data"> <form action="<?= route_to('person-create') ?>" method="POST" class="flex flex-col max-w-sm gap-y-4" enctype="multipart/form-data">
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Field <x-Forms.Field
name="avatar" name="avatar"
label="<?= esc(lang('Person.form.avatar')) ?>" label="<?= esc(lang('Person.form.avatar')) ?>"
helper="<?= esc(lang('Person.form.avatar_size_hint')) ?>" helper="<?= esc(lang('Person.form.avatar_size_hint')) ?>"
type="file" type="file"
accept=".jpg,.jpeg,.png" /> accept=".jpg,.jpeg,.png" />
<Forms.Field <x-Forms.Field
name="full_name" name="full_name"
label="<?= esc(lang('Person.form.full_name')) ?>" label="<?= esc(lang('Person.form.full_name')) ?>"
hint="<?= esc(lang('Person.form.full_name_hint')) ?>" hint="<?= esc(lang('Person.form.full_name_hint')) ?>"
required="true" isRequired="true"
data-slugify="title" /> data-slugify="title" />
<Forms.Field <x-Forms.Field
name="unique_name" name="unique_name"
label="<?= esc(lang('Person.form.unique_name')) ?>" label="<?= esc(lang('Person.form.unique_name')) ?>"
hint="<?= esc(lang('Person.form.unique_name_hint')) ?>" hint="<?= esc(lang('Person.form.unique_name_hint')) ?>"
required="true" isRequired="true"
data-slugify="slug" /> data-slugify="slug" />
<Forms.Field <x-Forms.Field
name="information_url" name="information_url"
label="<?= esc(lang('Person.form.information_url')) ?>" label="<?= esc(lang('Person.form.information_url')) ?>"
hint="<?= esc(lang('Person.form.information_url_hint')) ?>" /> hint="<?= esc(lang('Person.form.information_url_hint')) ?>" />
<Button variant="primary" class="self-end" type="submit"><?= lang('Person.form.submit_create') ?></Button> <x-Button variant="primary" class="self-end" type="submit"><?= lang('Person.form.submit_create') ?></x-Button>
</form> </form>

View File

@ -14,36 +14,36 @@
<form action="<?= route_to('person-edit', $person->id) ?>" method="POST" class="flex flex-col max-w-sm gap-y-4" enctype="multipart/form-data"> <form action="<?= route_to('person-edit', $person->id) ?>" method="POST" class="flex flex-col max-w-sm gap-y-4" enctype="multipart/form-data">
<?= csrf_field() ?> <?= csrf_field() ?>
<Forms.Field <x-Forms.Field
name="avatar" name="avatar"
label="<?= esc(lang('Person.form.avatar')) ?>" label="<?= esc(lang('Person.form.avatar')) ?>"
helper="<?= esc(lang('Person.form.avatar_size_hint')) ?>" helper="<?= esc(lang('Person.form.avatar_size_hint')) ?>"
type="file" type="file"
accept=".jpg,.jpeg,.png" /> accept=".jpg,.jpeg,.png" />
<Forms.Field <x-Forms.Field
name="full_name" name="full_name"
value="<?= esc($person->full_name) ?>" value="<?= esc($person->full_name) ?>"
label="<?= esc(lang('Person.form.full_name')) ?>" label="<?= esc(lang('Person.form.full_name')) ?>"
hint="<?= esc(lang('Person.form.full_name_hint')) ?>" hint="<?= esc(lang('Person.form.full_name_hint')) ?>"
required="true" isRequired="true"
data-slugify="title" /> data-slugify="title" />
<Forms.Field <x-Forms.Field
name="unique_name" name="unique_name"
value="<?= esc($person->unique_name) ?>" value="<?= esc($person->unique_name) ?>"
label="<?= esc(lang('Person.form.unique_name')) ?>" label="<?= esc(lang('Person.form.unique_name')) ?>"
hint="<?= esc(lang('Person.form.unique_name_hint')) ?>" hint="<?= esc(lang('Person.form.unique_name_hint')) ?>"
required="true" isRequired="true"
data-slugify="slug" /> data-slugify="slug" />
<Forms.Field <x-Forms.Field
name="information_url" name="information_url"
label="<?= esc(lang('Person.form.information_url')) ?>" label="<?= esc(lang('Person.form.information_url')) ?>"
hint="<?= esc(lang('Person.form.information_url_hint')) ?>" hint="<?= esc(lang('Person.form.information_url_hint')) ?>"
value="<?= esc($person->information_url) ?>" /> value="<?= esc($person->information_url) ?>" />
<Button variant="primary" class="self-end" type="submit"><?= lang('Person.form.submit_edit') ?></Button> <x-Button variant="primary" class="self-end" type="submit"><?= lang('Person.form.submit_edit') ?></x-Button>
</form> </form>

Some files were not shown because too many files have changed in this diff Show More