feat(pwa): add service-worker + webmanifest for each podcasts to have them install on devices

- configure service-worker using vite-plugin-pwa
- refactor Image entity to generate images of
different types based on size config
- add requirement for webp library for php gd to generate webp
images for instance
- add action to regenerate all instance images for eventual Images config
changes
- enhance google lighthouse metrics for pwa
This commit is contained in:
Yassine Doghri 2021-11-23 11:54:34 +00:00
parent 902f959b30
commit fee2c1c0d0
80 changed files with 5419 additions and 195 deletions

View File

@ -37,6 +37,7 @@ RUN apt-get update \
# https://github.com/mlocati/docker-php-extension-installer (included in php's docker image)
libicu-dev \
libpng-dev \
libwebp-dev \
libjpeg-dev \
zlib1g-dev \
libzip-dev \
@ -44,7 +45,7 @@ RUN apt-get update \
&& docker-php-ext-install intl \
&& docker-php-ext-install zip \
# gd for image processing
&& docker-php-ext-configure gd --with-jpeg \
&& docker-php-ext-configure gd --with-webp --with-jpeg \
&& docker-php-ext-install gd \
# redis extension for cache
&& pecl install -o -f redis \

View File

@ -69,7 +69,8 @@ PHP version 8.0 or higher is required, with the following extensions installed:
- [intl](https://php.net/manual/en/intl.requirements.php)
- [libcurl](https://php.net/manual/en/curl.requirements.php)
- [mbstring](https://php.net/manual/en/mbstring.installation.php)
- [gd](https://www.php.net/manual/en/image.installation.php)
- [gd](https://www.php.net/manual/en/image.installation.php) with **JPEG**,
**PNG** and **WEBP** libraries.
Additionally, make sure that the following extensions are enabled in your PHP:

View File

@ -47,15 +47,57 @@ class Images extends BaseConfig
*
* Array values are as follows: 'name' => [width, height]
*
* @var array<string, int[]>
* @var array<string, array<string, int|string>>
*/
public array $podcastCoverSizes = [
'tiny' => [40, 40],
'thumbnail' => [150, 150],
'medium' => [320, 320],
'large' => [1024, 1024],
'feed' => [1400, 1400],
'id3' => [500, 500],
'tiny' => [
'width' => 40,
'height' => 40,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'thumbnail' => [
'width' => 150,
'height' => 150,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'medium' => [
'width' => 320,
'height' => 320,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'large' => [
'width' => 1024,
'height' => 1024,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'feed' => [
'width' => 1400,
'height' => 1400,
],
'id3' => [
'width' => 500,
'height' => 500,
],
'federation' => [
'width' => 400,
'height' => 400,
],
'webmanifest192' => [
'width' => 192,
'height' => 192,
'mimetype' => 'image/png',
'extension' => 'png',
],
'webmanifest512' => [
'width' => 512,
'height' => 512,
'mimetype' => 'image/png',
'extension' => 'png',
],
];
/**
@ -63,14 +105,25 @@ class Images extends BaseConfig
*
* Uploaded podcast header covers are of 3:1 ratio
*
* Array values are as follows: 'name' => [width, height]
*
* @var array<string, int[]>
* @var array<string, array<string, int|string>>
*/
public array $podcastBannerSizes = [
'small' => [320, 128],
'medium' => [960, 320],
'large' => [1500, 500],
'small' => [
'width' => 320,
'height' => 128,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'medium' => [
'width' => 960,
'height' => 320,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'federation' => [
'width' => 1500,
'height' => 500,
],
];
public string $podcastBannerDefaultPath = 'castopod-banner-default.jpg';
@ -84,11 +137,27 @@ class Images extends BaseConfig
*
* Array values are as follows: 'name' => [width, height]
*
* @var array<string, int[]>
* @var array<string, array<string, int|string>>
*/
public array $personAvatarSizes = [
'tiny' => [40, 40],
'thumbnail' => [150, 150],
'medium' => [320, 320],
'tiny' => [
'width' => 40,
'height' => 40,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'thumbnail' => [
'width' => 150,
'height' => 150,
'mimetype' => 'image/webp',
'extension' => 'webp',
],
'medium' => [
'width' => 320,
'height' => 320,
'mimetype' =>
'image/webp',
'extension' => 'webp',
],
];
}

View File

@ -65,6 +65,10 @@ $routes->group('@(:podcastHandle)', function ($routes): void {
$routes->get('/', 'PodcastController::activity/$1', [
'as' => 'podcast-activity',
]);
$routes->get('manifest.webmanifest', 'WebmanifestController::podcastManifest/$1', [
'as' => 'podcast-webmanifest',
]);
// override default Fediverse Library's actor route
$routes->options('/', 'ActivityPubController::preflight');
$routes->get('/', 'PodcastController::activity/$1', [

View File

@ -204,9 +204,9 @@ class EpisodeController extends BaseController
'height' => 144,
'thumbnail_url' => $this->episode->cover->large_url,
'thumbnail_width' => config('Images')
->podcastCoverSizes['large'][0],
->podcastCoverSizes['large']['width'],
'thumbnail_height' => config('Images')
->podcastCoverSizes['large'][1],
->podcastCoverSizes['large']['height'],
]);
}
@ -222,8 +222,8 @@ class EpisodeController extends BaseController
$oembed->addChild('author_name', $this->podcast->title);
$oembed->addChild('author_url', $this->podcast->link);
$oembed->addChild('thumbnail', $this->episode->cover->large_url);
$oembed->addChild('thumbnail_width', (string) config('Images')->podcastCoverSizes['large'][0]);
$oembed->addChild('thumbnail_height', (string) config('Images')->podcastCoverSizes['large'][1]);
$oembed->addChild('thumbnail_width', (string) config('Images')->podcastCoverSizes['large']['width']);
$oembed->addChild('thumbnail_height', (string) config('Images')->podcastCoverSizes['large']['height']);
$oembed->addChild(
'html',
htmlentities(

View File

@ -10,11 +10,44 @@ declare(strict_types=1);
namespace App\Controllers;
use App\Models\PodcastModel;
use CodeIgniter\Controller;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\ResponseInterface;
class WebmanifestController extends Controller
{
/**
* @var array<string, string>
*/
public const THEME_COLORS = [
'pine' => [
'theme' => '#009486',
'background' => '#F0F9F8',
],
'lake' => [
'theme' => '#00ACE0',
'background' => '#F0F7F9',
],
'jacaranda' => [
'theme' => '#562CDD',
'background' => '#F2F0F9',
],
'crimson' => [
'theme' => '#F24562',
'background' => '#F9F0F2',
],
'amber' => [
'theme' => '#FF6224',
'background' => '#F9F3F0',
],
'onyx' => [
'theme' =>
'#040406',
'background' => '#F3F3F7',
],
];
public function index(): ResponseInterface
{
$webmanifest = [
@ -22,8 +55,13 @@ class WebmanifestController extends Controller
->get('App.siteName'),
'description' => service('settings')
->get('App.siteDescription'),
'lang' => service('request')
->getLocale(),
'start_url' => base_url(),
'display' => 'minimal-ui',
'theme_color' => '#009486',
'orientation' => 'portrait',
'theme_color' => self::THEME_COLORS[service('settings')->get('App.theme')]['theme'],
'background_color' => self::THEME_COLORS[service('settings')->get('App.theme')]['background'],
'icons' => [
[
'src' => service('settings')
@ -42,4 +80,39 @@ class WebmanifestController extends Controller
return $this->response->setJSON($webmanifest);
}
public function podcastManifest(string $podcastHandle): ResponseInterface
{
if (
($podcast = (new PodcastModel())->getPodcastByHandle($podcastHandle)) === null
) {
throw PageNotFoundException::forPageNotFound();
}
$webmanifest = [
'name' => $podcast->title,
'short_name' => '@' . $podcast->handle,
'description' => $podcast->description,
'lang' => $podcast->language_code,
'start_url' => $podcast->link,
'display' => 'minimal-ui',
'orientation' => 'portrait',
'theme_color' => self::THEME_COLORS[service('settings')->get('App.theme')]['theme'],
'background_color' => self::THEME_COLORS[service('settings')->get('App.theme')]['background'],
'icons' => [
[
'src' => $podcast->cover->webmanifest192_url,
'type' => $podcast->cover->webmanifest192_mimetype,
'sizes' => '192x192',
],
[
'src' => $podcast->cover->webmanifest512_url,
'type' => $podcast->cover->webmanifest512_mimetype,
'sizes' => '512x512',
],
],
];
return $this->response->setJSON($webmanifest);
}
}

View File

@ -41,4 +41,22 @@ class Actor extends FediverseActor
return $this->podcast;
}
public function getAvatarImageUrl(): string
{
if ($this->podcast !== null) {
return $this->podcast->cover->thumbnail_url;
}
return $this->attributes['avatar_image_url'];
}
public function getAvatarImageMimetype(): string
{
if ($this->podcast !== null) {
return $this->podcast->cover->thumbnail_mimetype;
}
return $this->attributes['avatar_image_mimetype'];
}
}

View File

@ -200,7 +200,9 @@ class Episode extends Entity
public function getCover(): Image
{
if ($coverPath = $this->attributes['cover_path']) {
return new Image(null, $coverPath, $this->attributes['cover_mimetype']);
return new Image(null, $coverPath, $this->attributes['cover_mimetype'], config(
'Images'
)->podcastCoverSizes);
}
return $this->getPodcast()

View File

@ -28,7 +28,7 @@ class Image extends Entity
{
protected Images $config;
protected ?File $file = null;
protected File $file;
protected string $dirname;
@ -38,7 +38,16 @@ class Image extends Entity
protected string $mimetype;
public function __construct(?File $file, string $path = '', string $mimetype = '')
/**
* @var array<string, array<string, int|string>>
*/
protected array $sizes = [];
/**
* @param array<string, array<string, int|string>> $sizes
* @param File $file
*/
public function __construct(?File $file, string $path = '', string $mimetype = '', array $sizes = [])
{
if ($file === null && $path === '') {
throw new RuntimeException('File or path must be set to create an Image.');
@ -63,11 +72,17 @@ class Image extends Entity
] = pathinfo($path);
}
if ($file === null) {
helper('media');
$file = new File(media_path($path));
}
$this->file = $file;
$this->dirname = $dirname;
$this->filename = $filename;
$this->extension = $extension;
$this->mimetype = $mimetype;
$this->sizes = $sizes;
}
public function __get($property)
@ -91,7 +106,24 @@ class Image extends Entity
if ($this->dirname !== '.') {
$path .= $this->dirname . '/';
}
$path .= $this->filename . $fileSuffix . '.' . $this->extension;
$path .= $this->filename . $fileSuffix;
$extension = '.' . $this->extension;
$mimetype = $this->mimetype;
if ($fileSuffix !== '') {
$sizeName = substr($fileSuffix, 1);
if (array_key_exists('extension', $this->sizes[$sizeName])) {
$extension = '.' . $this->sizes[$sizeName]['extension'];
}
if (array_key_exists('mimetype', $this->sizes[$sizeName])) {
$mimetype = $this->sizes[$sizeName]['mimetype'];
}
}
$path .= $extension;
if (str_ends_with($property, 'mimetype')) {
return $mimetype;
}
if (str_ends_with($property, 'url')) {
helper('media');
@ -111,15 +143,11 @@ class Image extends Entity
public function getFile(): File
{
if ($this->file === null) {
$this->file = new File($this->path);
}
return $this->file;
}
/**
* @param array<string, int[]> $sizes
* @param array<string, array<string, int|string>> $sizes
*/
public function saveImage(array $sizes, string $dirname, string $filename): void
{
@ -127,6 +155,7 @@ class Image extends Entity
$this->dirname = $dirname;
$this->filename = $filename;
$this->sizes = $sizes;
save_media($this->file, $this->dirname, $this->filename);
@ -136,8 +165,8 @@ class Image extends Entity
$pathProperty = $name . '_path';
$imageService
->withFile(media_path($this->path))
->resize($size[0], $size[1])
->save(media_path($this->{$pathProperty}));
->resize($size['width'], $size['height']);
$imageService->save(media_path($this->{$pathProperty}));
}
}

View File

@ -77,10 +77,12 @@ class Person extends Entity
public function getAvatar(): Image
{
if ($this->attributes['avatar_path'] === null) {
return new Image(null, '/castopod-avatar-default.jpg', 'image/jpeg');
return new Image(null, '/castopod-avatar-default.jpg', 'image/jpeg', config('Images')->personAvatarSizes);
}
return new Image(null, $this->attributes['avatar_path'], $this->attributes['avatar_mimetype']);
return new Image(null, $this->attributes['avatar_path'], $this->attributes['avatar_mimetype'], config(
'Images'
)->personAvatarSizes);
}
/**

View File

@ -211,7 +211,7 @@ class Podcast extends Entity
public function getCover(): Image
{
return new Image(null, $this->cover_path, $this->cover_mimetype);
return new Image(null, $this->cover_path, $this->cover_mimetype, config('Images')->podcastCoverSizes);
}
/**
@ -248,11 +248,13 @@ class Podcast extends Entity
config('Images')
->podcastBannerDefaultPath,
config('Images')
->podcastBannerDefaultMimeType
->podcastBannerDefaultMimeType,
config('Images')
->podcastBannerSizes
);
}
return new Image(null, $this->banner_path, $this->banner_mimetype);
return new Image(null, $this->banner_path, $this->banner_mimetype, config('Images') ->podcastBannerSizes);
}
public function getLink(): string

View File

@ -24,7 +24,7 @@ if (! function_exists('get_podcast_metatags')) {
$schema = new Schema(
new Thing('PodcastSeries', [
'name' => $podcast->title,
'url' => url_to('podcast-activity', $podcast->handle),
'url' => $podcast->link,
'image' => $podcast->cover->feed_url,
'description' => $podcast->description,
'webFeed' => $podcast->feed_url,
@ -41,8 +41,8 @@ if (! function_exists('get_podcast_metatags')) {
->description(htmlspecialchars($podcast->description))
->image((string) $podcast->cover->large_url)
->canonical((string) current_url())
->og('image:width', (string) config('Images')->podcastCoverSizes['large'][0])
->og('image:height', (string) config('Images')->podcastCoverSizes['large'][1])
->og('image:width', (string) config('Images')->podcastCoverSizes['large']['width'])
->og('image:height', (string) config('Images')->podcastCoverSizes['large']['height'])
->og('locale', $podcast->language_code)
->og('site_name', service('settings')->get('App.siteName'));
@ -70,7 +70,7 @@ if (! function_exists('get_episode_metatags')) {
]),
'partOfSeries' => new Thing('PodcastSeries', [
'name' => $episode->podcast->title,
'url' => url_to('podcast-activity', $episode->podcast->handle),
'url' => $episode->podcast->link,
]),
])
);
@ -83,8 +83,8 @@ if (! function_exists('get_episode_metatags')) {
->image((string) $episode->cover->large_url, 'player')
->canonical($episode->link)
->og('site_name', service('settings')->get('App.siteName'))
->og('image:width', (string) config('Images')->podcastCoverSizes['large'][0])
->og('image:height', (string) config('Images')->podcastCoverSizes['large'][1])
->og('image:width', (string) config('Images')->podcastCoverSizes['large']['width'])
->og('image:height', (string) config('Images')->podcastCoverSizes['large']['height'])
->og('locale', $episode->podcast->language_code)
->og('audio', $episode->audio_file_opengraph_url)
->og('audio:type', $episode->audio_file_mimetype)

View File

@ -485,9 +485,9 @@ class PodcastModel extends Model
// update values
$actor->display_name = $podcast->title;
$actor->summary = $podcast->description_html;
$actor->avatar_image_url = $podcast->cover->thumbnail_url;
$actor->avatar_image_url = $podcast->cover->federation_url;
$actor->avatar_image_mimetype = $podcast->cover->mimetype;
$actor->cover_image_url = $podcast->banner->large_url;
$actor->cover_image_url = $podcast->banner->federation_url;
$actor->cover_image_mimetype = $podcast->banner->mimetype;
if ($actor->hasChanged()) {

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm4.82-4.924A7 7 0 0 0 9.032 5.658l.975 1.755A5 5 0 0 1 17 12h-3l2.82 5.076zm-1.852 1.266l-.975-1.755A5 5 0 0 1 7 12h3L7.18 6.924a7 7 0 0 0 7.788 11.418z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 377 B

6
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c0a25c3d11c806b4bc62eafb22902bc8",
"content-hash": "afb6585b90ed08cc8a257f346ab1c416",
"packages": [
{
"name": "brick/math",
@ -603,8 +603,8 @@
"php": ">=5.4.0"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.0",
"phpunit/phpunit": "^4.8 || ^5.0 || ^6.1 || ^7.5 || ^8.5"
"jakub-onderka/php-parallel-lint": "^0.9 || ^1.0",
"phpunit/phpunit": "^4.8|^5.0"
},
"suggest": {
"ext-SimpleXML": "SimpleXML extension is required to analyze RIFF/WAV/BWF audio files (also requires `ext-libxml`).",

View File

@ -153,8 +153,8 @@ You do not wish to use the VSCode devcontainer? No problem!
> The `docker-compose up -d` command will boot 4 containers in the
> background:
>
> - `castopod-host_app`: a php based container with CodeIgniter4 requirements
> installed
> - `castopod-host_app`: a php based container with Castopod Host
> requirements installed
> - `castopod-host_redis`: a [redis](https://redis.io/) database to handle
> queries and pages caching
> - `castopod-host_mariadb`: a [mariadb](https://mariadb.org/) server for

View File

@ -31,6 +31,10 @@ $routes->group(
'as' => 'settings-instance-delete-icon',
'filter' => 'permission:settings-manage',
]);
$routes->post('instance-images-regenerate', 'SettingsController::regenerateImages', [
'as' => 'settings-images-regenerate',
'filter' => 'permission:settings-manage',
]);
$routes->get('theme', 'SettingsController::theme', [
'as' => 'settings-theme',
'filter' => 'permission:settings-manage',

View File

@ -10,6 +10,8 @@ declare(strict_types=1);
namespace Modules\Admin\Controllers;
use App\Models\PersonModel;
use App\Models\PodcastModel;
use CodeIgniter\HTTP\RedirectResponse;
use PHP_ICO;
@ -75,20 +77,20 @@ class SettingsController extends BaseController
service('image')
->withFile(ROOTPATH . 'public/media/site/icon.png')
->resize($size, $size)
->save(ROOTPATH . "public/media/site/icon-{$size}.{$randomHash}.png");
->save(media_path("/site/icon-{$size}.{$randomHash}.png"));
}
service('settings')
->set('App.siteIcon', [
'ico' => "/media/site/favicon.{$randomHash}.ico",
'64' => "/media/site/icon-64.{$randomHash}.png",
'180' => "/media/site/icon-180.{$randomHash}.png",
'192' => "/media/site/icon-192.{$randomHash}.png",
'512' => "/media/site/icon-512.{$randomHash}.png",
'ico' => media_path("/site/favicon.{$randomHash}.ico"),
'64' => media_path("/site/icon-64.{$randomHash}.png"),
'180' => media_path("/site/icon-180.{$randomHash}.png"),
'192' => media_path("/site/icon-192.{$randomHash}.png"),
'512' => media_path("/site/icon-512.{$randomHash}.png"),
]);
}
return redirect('settings-general')->with('message', lang('Settings.general.instanceEditSuccess'));
return redirect('settings-general')->with('message', lang('Settings.instance.editSuccess'));
}
public function deleteIcon(): RedirectResponse
@ -100,7 +102,52 @@ class SettingsController extends BaseController
service('settings')
->forget('App.siteIcon');
return redirect('settings-general')->with('message', lang('Settings.general.deleteIconSuccess'));
return redirect('settings-general')->with('message', lang('Settings.instance.deleteIconSuccess'));
}
public function regenerateImages(): RedirectResponse
{
$allPodcasts = (new PodcastModel())->findAll();
foreach ($allPodcasts as $podcast) {
$podcastImages = glob(ROOTPATH . "public/media/podcasts/{$podcast->handle}/*_*");
if ($podcastImages) {
foreach ($podcastImages as $podcastImage) {
if (is_file($podcastImage)) {
unlink($podcastImage);
}
}
}
$podcast->setCover($podcast->cover);
if ($podcast->banner_path !== null) {
$podcast->setBanner($podcast->banner);
}
foreach ($podcast->episodes as $episode) {
if ($episode->cover_path !== null) {
$episode->setCover($episode->cover);
}
}
}
$personsImages = glob(ROOTPATH . 'public/media/persons/*_*');
if ($personsImages) {
foreach ($personsImages as $personsImage) {
if (is_file($personsImage)) {
unlink($personsImage);
}
}
}
$persons = (new PersonModel())->findAll();
foreach ($persons as $person) {
if ($person->avatar_path !== null) {
$person->setAvatar($person->avatar);
}
}
return redirect('settings-general')->with('message', lang('Settings.images.regenerationSuccess'));
}
public function theme(): string

View File

@ -9,6 +9,7 @@ declare(strict_types=1);
*/
return [
'toggle_sidebar' => 'Toggle sidebar',
'go_to_website' => 'View site',
'go_to_admin' => 'Go to admin',
'dashboard' => 'Dashboard',

View File

@ -10,8 +10,8 @@ declare(strict_types=1);
return [
'title' => 'General settings',
'general' => [
'site_section_title' => 'Instance',
'instance' => [
'title' => 'Instance',
'site_icon' => 'Site icon',
'site_icon_delete' => 'Delete site icon',
'site_icon_hint' => 'Site icons are what you see on your browser tabs, bookmarks bar, and when you add a website as a shortcut on mobile devices.',
@ -19,9 +19,15 @@ return [
'site_name' => 'Site name',
'site_description' => 'Site description',
'submit' => 'Save',
'instanceEditSuccess' => 'Instance has been updated successfully!',
'editSuccess' => 'Instance has been updated successfully!',
'deleteIconSuccess' => 'Site icon has been remove successfully!',
],
'images' => [
'title' => 'Images',
'subtitle' => 'Here you can regenerate all images based on the originals that were uploaded.',
'regenerate' => 'Regenerate images',
'regenerationSuccess' => 'All images have been regenerated successfully!',
],
'theme' => [
'title' => 'Theme',
'accent_section_title' => 'Accent color',

View File

@ -9,6 +9,7 @@ declare(strict_types=1);
*/
return [
'toggle_sidebar' => 'Afficher ou cacher la barre latérale',
'go_to_website' => 'Visiter le site',
'dashboard' => 'Tableau de bord',
'admin' => 'Accueil',

View File

@ -10,8 +10,8 @@ declare(strict_types=1);
return [
'title' => 'Paramètres généraux',
'general' => [
'site_section_title' => 'Instance',
'instance' => [
'title' => 'Instance',
'site_icon' => 'Favicon du site',
'site_icon_delete' => 'Supprimer la favicon du site',
'site_icon_hint' => 'Les favicons sont ce que vous voyez sur les onglets de votre navigateur, dans votre barre de favoris, et lorsque vous ajoutez un site web en raccourci sur des appareils mobiles.',
@ -19,9 +19,15 @@ return [
'site_name' => 'Titre du site',
'site_description' => 'Description du site',
'submit' => 'Sauvegarder',
'instanceEditSuccess' => 'Linstance a bien été mise à jour!',
'editSuccess' => 'Linstance a bien été mise à jour!',
'deleteIconSuccess' => 'La favicon du site a bien été retirée!',
],
'images' => [
'title' => 'Images',
'subtitle' => 'Vous pouvez ici regénérer toutes les images en se basant sur celles qui ont été téléversées à lorigine.',
'regenerate' => 'Regénérer les images',
'regenerationSuccess' => 'Toutes les images ont été regénérés avec succès!',
],
'theme' => [
'title' => 'Thème',
'accent_section_title' => 'Couleur daccentuation',
@ -32,6 +38,7 @@ return [
'lake' => 'Lac',
'jacaranda' => 'Jacaranda',
'onyx' => 'Onyx',
'submit' => 'Sauvegarder',
'setInstanceThemeSuccess' => 'Le thème a bien été mis à jour!',
],
];

4980
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -83,7 +83,12 @@
"svgo": "^2.8.0",
"tailwindcss": "^3.0.0-alpha.1",
"typescript": "^4.4.4",
"vite": "^2.6.13"
"vite": "^2.6.13",
"vite-plugin-pwa": "^0.11.5",
"workbox-build": "^6.4.0",
"workbox-core": "^6.4.0",
"workbox-routing": "^6.4.0",
"workbox-strategies": "^6.4.0"
},
"lint-staged": {
"*.{js,ts,css,md,json}": "prettier --write",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -52,6 +52,7 @@ module.exports = {
subtle: withOpacity("--color-border-subtle"),
contrast: withOpacity("--color-border-contrast"),
navigation: withOpacity("--color-border-navigation"),
"navigation-bg": withOpacity("--color-background-navigation"),
accent: {
base: withOpacity("--color-accent-base"),
hover: withOpacity("--color-accent-hover"),

View File

@ -1,7 +1,7 @@
<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"
data-sidebar-toggler="toggler"
class="h-full pr-1 text-xl md:hidden focus:ring-accent focus:ring-inset"><?= icon('menu') ?></button>
class="h-full pr-1 text-xl md:hidden focus:ring-accent focus:ring-inset" aria-label="<?= lang('Navigation.toggle_sidebar') ?>"><?= icon('menu') ?></button>
<div class="inline-flex items-center h-full">
<a href="<?= route_to(
'admin',
@ -25,7 +25,7 @@
aria-expanded="false"><div class="relative mr-1">
<?= icon('account-circle', 'text-3xl opacity-60') ?>
<?= user()
->podcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 rounded-full -right-1" />' ?>
->podcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 border rounded-full -right-1 border-navigation-bg" />' ?>
</div>
<?= user()->username ?>
<?= icon('caret-down', 'ml-auto text-2xl') ?></button>

View File

@ -2,7 +2,7 @@
<a href="<?= route_to('episode-view', $episode->podcast->id, $episode->id) ?>" class="flex flex-col justify-end w-full h-full text-white group">
<div class="absolute bottom-0 left-0 z-10 w-full h-full backdrop-gradient"></div>
<div class="w-full h-full overflow-hidden">
<img src="<?= $episode->cover->medium_url ?>" alt="<?= $episode->title ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform group-focus:scale-105 group-hover:scale-105" />
<img src="<?= $episode->cover->medium_url ?>" alt="<?= $episode->title ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform group-focus:scale-105 group-hover:scale-105 aspect-square" />
</div>
<?= publication_pill($episode->published_at, $episode->publication_status, 'absolute top-0 left-0 ml-2 mt-2 text-sm'); ?>
<div class="absolute z-20 flex flex-col items-start px-4 py-2">

View File

@ -12,7 +12,7 @@ $podcastNavigation = [
<img
src="<?= $podcast->cover->tiny_url ?>"
alt="<?= $podcast->title ?>"
class="object-cover w-6 h-6 rounded"
class="object-cover w-6 h-6 rounded aspect-square"
/>
<span class="flex-1 w-full px-2 text-xs font-semibold truncate" title="<?= $podcast->title ?>"><?= $podcast->title ?></span>
</a>
@ -20,7 +20,7 @@ $podcastNavigation = [
<img
src="<?= $episode->cover->thumbnail_url ?>"
alt="<?= $episode->title ?>"
class="object-cover w-16 h-16 rounded"
class="object-cover w-16 h-16 rounded aspect-square"
/>
<div class="flex flex-col items-start flex-1 w-48 px-2">
<span class="w-full font-semibold truncate" title="<?= $episode->title ?>"><?= $episode->title ?></span>

View File

@ -34,7 +34,7 @@
$episode->audio_file_duration,
) .
'</time>' .
'<img loading="lazy" src="' . $episode->cover->thumbnail_url . '" alt="' . $episode->title . '" class="object-cover w-20 h-20 rounded-lg" />' .
'<img loading="lazy" src="' . $episode->cover->thumbnail_url . '" alt="' . $episode->title . '" class="object-cover w-20 rounded-lg aspect-square" />' .
'</div>' .
'<a class="overflow-x-hidden text-sm hover:underline" href="' . route_to(
'episode-view',

View File

@ -58,7 +58,7 @@
return '<div class="flex">' .
'<a href="' .
route_to('person-view', $person->id) .
"\"><img src=\"{$person->avatar->thumbnail_url}\" alt=\"{$person->full_name}\" class=\"object-cover w-16 h-16 rounded-full\" /></a>" .
"\"><img src=\"{$person->avatar->thumbnail_url}\" alt=\"{$person->full_name}\" class=\"object-cover w-16 rounded-full aspect-square\" /></a>" .
'<div class="flex flex-col ml-3">' .
$person->full_name .
implode(

View File

@ -28,7 +28,7 @@
<small class="max-w-md mb-2 text-skin-muted"><?= lang('Episode.publish_form.post_hint') ?></small>
<div class="mb-8 overflow-hidden shadow-md bg-elevated rounded-xl">
<div class="flex px-4 py-3 gap-x-2">
<img src="<?= $podcast->actor->avatar_image_url ?>" alt="<?= $podcast->actor->display_name ?>" class="w-10 h-10 rounded-full" />
<img src="<?= $podcast->actor->avatar_image_url ?>" alt="<?= $podcast->actor->display_name ?>" class="w-10 h-10 rounded-full aspect-square" />
<div class="flex flex-col min-w-0">
<p class="flex items-baseline min-w-0">
<span class="mr-2 font-semibold truncate"><?= $podcast->actor->display_name ?></span>
@ -41,7 +41,7 @@
</div>
<div class="flex border-y">
<img src="<?= $episode->cover
->thumbnail_url ?>" alt="<?= $episode->title ?>" class="w-24 h-24" />
->thumbnail_url ?>" alt="<?= $episode->title ?>" class="w-24 h-24 aspect-square" />
<div class="flex flex-col flex-1">
<a href="<?= $episode->link ?>" class="flex-1 px-4 py-2">
<div class="flex items-baseline">

View File

@ -29,7 +29,7 @@
<small class="max-w-md mb-2 text-skin-muted"><?= lang('Episode.publish_form.post_hint') ?></small>
<div class="mb-8 overflow-hidden shadow-md bg-elevated rounded-xl">
<div class="flex px-4 py-3 gap-x-2">
<img src="<?= $podcast->actor->avatar_image_url ?>" alt="<?= $podcast->actor->display_name ?>" class="w-10 h-10 rounded-full" />
<img src="<?= $podcast->actor->avatar_image_url ?>" alt="<?= $podcast->actor->display_name ?>" class="w-10 h-10 rounded-full aspect-square" />
<div class="flex flex-col min-w-0">
<p class="flex items-baseline min-w-0">
<span class="mr-2 font-semibold truncate"><?= $podcast->actor->display_name ?></span>
@ -43,7 +43,7 @@
</div>
<div class="flex border-y">
<img src="<?= $episode->cover
->thumbnail_url ?>" alt="<?= $episode->title ?>" class="w-24 h-24" />
->thumbnail_url ?>" alt="<?= $episode->title ?>" class="w-24 h-24 aspect-square" />
<div class="flex flex-col flex-1">
<a href="<?= $episode->link ?>" class="flex-1 px-4 py-2">
<div class="flex items-baseline">

View File

@ -2,7 +2,7 @@
<a href="<?= route_to('person-view', $person->id) ?>" class="flex flex-col justify-end w-full h-full text-white group">
<div class="absolute bottom-0 left-0 z-10 w-full h-full backdrop-gradient"></div>
<div class="w-full h-full overflow-hidden">
<img alt="<?= $person->full_name ?>" src="<?= $person->avatar->medium_url ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform group-focus:scale-105 group-hover:scale-105" />
<img alt="<?= $person->full_name ?>" src="<?= $person->avatar->medium_url ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform aspect-square group-focus:scale-105 group-hover:scale-105" />
</div>
<div class="absolute z-20">
<h2 class="px-4 py-2 font-semibold leading-tight"><?= $person->full_name ?></h2>

View File

@ -19,7 +19,7 @@
<img
src="<?= $person->avatar->medium_url ?>"
alt="$person->full_name"
class="object-cover w-full max-w-xs rounded"
class="object-cover w-full max-w-xs rounded aspect-square"
/>
<div class="flex flex-col">
<?= $person->full_name ?>

View File

@ -4,7 +4,7 @@
<div class="w-full h-full overflow-hidden">
<img
alt="<?= $podcast->title ?>"
src="<?= $podcast->cover->medium_url ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform group-focus:scale-105 group-hover:scale-105" />
src="<?= $podcast->cover->medium_url ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform aspect-square group-focus:scale-105 group-hover:scale-105" />
</div>
<div class="absolute z-20 px-4 pb-4 transition duration-75 ease-out translate-y-6 group-focus:translate-y-0 group-hover:translate-y-0">
<h2 class="font-bold leading-none truncate font-display"><?= $podcast->title ?></h2>

View File

@ -39,7 +39,7 @@ $podcastNavigation = [
<img
src="<?= $podcast->cover->thumbnail_url ?>"
alt="<?= $podcast->title ?>"
class="object-cover w-16 h-16 rounded"
class="object-cover w-16 h-16 rounded aspect-square"
/>
<div class="flex flex-col items-start flex-1 w-48 px-2">
<span class="w-full font-semibold truncate" title="<?= $podcast->title ?>"><?= $podcast->title ?></span>

View File

@ -28,7 +28,7 @@
<img src="<?= $podcast->banner->small_url ?>" alt="" class="object-cover w-full aspect-[3/1] bg-header" />
<div class="flex px-4 py-2">
<img src="<?= $podcast->cover->thumbnail_url ?>" alt="<?= $podcast->title ?>"
class="w-16 h-16 mr-4 -mt-8 rounded-full ring-2 ring-background-elevated" />
class="w-16 h-16 mr-4 -mt-8 rounded-full ring-2 ring-background-elevated aspect-square" />
<div class="flex flex-col">
<p class="font-semibold leading-none"><?= $podcast->title ?></p>
<p class="text-sm text-skin-muted">@<?= $podcast->handle ?></p>

View File

@ -55,7 +55,7 @@
return '<div class="flex">' .
'<a href="' .
route_to('person-view', $person->id) .
"\"><img src=\"{$person->avatar->thumbnail_url}\" alt=\"{$person->full_name}\" class=\"object-cover w-16 h-16 rounded-full\" /></a>" .
"\"><img src=\"{$person->avatar->thumbnail_url}\" alt=\"{$person->full_name}\" class=\"object-cover aspect-square w-16 h-16 rounded-full\" /></a>" .
'<div class="flex flex-col ml-3">' .
$person->full_name .
implode(

View File

@ -9,16 +9,17 @@
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<div class="flex flex-col gap-y-4">
<form action="<?= route_to('settings-instance') ?>" method="POST" class="flex flex-col gap-y-4" enctype="multipart/form-data">
<form action="<?= route_to('settings-instance') ?>" method="POST" enctype="multipart/form-data">
<?= csrf_field() ?>
<Forms.Section
title="<?= lang('Settings.general.site_section_title') ?>">
title="<?= lang('Settings.instance.title') ?>">
<Forms.Field
name="site_name"
label="<?= lang('Settings.general.site_name') ?>"
label="<?= lang('Settings.instance.site_name') ?>"
value="<?= service('settings')
->get('App.siteName') ?>"
required="true" />
@ -26,7 +27,7 @@
<Forms.Field
as="Textarea"
name="site_description"
label="<?= lang('Settings.general.site_description') ?>"
label="<?= lang('Settings.instance.site_description') ?>"
value="<?= service('settings')
->get('App.siteDescription') ?>"
required="true"
@ -36,24 +37,38 @@
<Forms.Field
name="site_icon"
type="file"
label="<?= lang('Settings.general.site_icon') ?>"
hint="<?= lang('Settings.general.site_icon_hint') ?>"
helper="<?= lang('Settings.general.site_icon_helper') ?>"
label="<?= lang('Settings.instance.site_icon') ?>"
hint="<?= lang('Settings.instance.site_icon_hint') ?>"
helper="<?= lang('Settings.instance.site_icon_helper') ?>"
accept=".png,.jpeg,.jpg"
class="flex-1"
/>
<?php if (config('App')->siteIcon['ico'] !== service('settings')->get('App.siteIcon')['ico']): ?>
<div class="relative ml-2">
<a href="<?= route_to('settings-instance-delete-icon') ?>" class="absolute p-1 text-red-700 bg-red-100 border-2 rounded-full hover:text-red-900 border-contrast -top-3 -right-3 focus:ring-accent" title="<?= lang('Settings.general.site_icon_delete') ?>" data-tooltip="top"><?= icon('delete-bin') ?></a>
<img src="<?= service('settings')->get('App.siteIcon')['64'] ?>" alt="<?= service('settings')->get('App.siteName') ?> Favicon" class="w-10 h-10" />
<a href="<?= route_to('settings-instance-delete-icon') ?>" class="absolute p-1 text-red-700 bg-red-100 border-2 rounded-full hover:text-red-900 border-contrast -top-3 -right-3 focus:ring-accent" title="<?= lang('Settings.instance.site_icon_delete') ?>" data-tooltip="top"><?= icon('delete-bin') ?></a>
<img src="<?= service('settings')->get('App.siteIcon')['64'] ?>" alt="<?= service('settings')->get('App.siteName') ?> Favicon" class="w-10 h-10 aspect-square" />
</div>
<?php endif; ?>
</div>
<Button variant="primary" type="submit" class="self-end"><?= lang('Settings.general.submit') ?></Button>
<Button variant="primary" type="submit" class="self-end"><?= lang('Settings.instance.submit') ?></Button>
</Forms.Section>
</form>
<form action="<?= route_to('settings-images-regenerate') ?>" method="POST" class="flex flex-col gap-y-4">
<?= csrf_field() ?>
<Forms.Section
title="<?= lang('Settings.images.title') ?>"
subtitle="<?= lang('Settings.images.subtitle') ?>" >
<Button variant="primary" type="submit" iconLeft="refresh"><?= lang('Settings.images.regenerate') ?></Button>
</Forms.Section>
</form>
</div>
<?= $this->endSection() ?>

View File

@ -20,7 +20,7 @@
aria-expanded="false"><div class="relative mr-1">
<?= icon('account-circle', 'text-3xl opacity-60') ?>
<?= user()
->podcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 border rounded-full -right-1 border-navigation" />' ?>
->podcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 border rounded-full -right-1 border-navigation-bg" />' ?>
</div>
<?= user()->username ?>
<?= icon('caret-down', 'ml-auto text-2xl') ?></button>

View File

@ -17,7 +17,7 @@
<div class="flex flex-col items-start p-4 gap-y-4">
<?php foreach ($persons as $person): ?>
<div class="flex gap-x-2">
<img src="<?= $person->avatar->thumbnail_url ?>" alt="<?= $person->full_name ?>" class="object-cover w-10 h-10 rounded-full" />
<img src="<?= $person->avatar->thumbnail_url ?>" alt="<?= $person->full_name ?>" class="object-cover w-10 rounded-full aspect-square" />
<div class="flex flex-col">
<h4 class="text-sm font-semibold">
<?php if ($person->information_url): ?>

View File

@ -12,8 +12,6 @@
<link rel="icon" type="image/x-icon" href="<?= service('settings')
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<link rel="canonical" href="<?= $episode->link ?>" />
<?= service('vite')
->asset('styles/index.css', 'css') ?>
<?= service('vite')
@ -21,7 +19,7 @@
</head>
<body class="flex" style="background: <?= $themeData['background'] ?>; color: <?= $themeData['text'] ?>;">
<img src="<?= $episode->cover->thumbnail_url ?>" alt="<?= $episode->title ?>" class="flex-shrink w-36 h-36" />
<img src="<?= $episode->cover->thumbnail_url ?>" alt="<?= $episode->title ?>" class="flex-shrink w-36 h-36 aspect-square" />
<div class="flex flex-col items-start flex-1 min-w-0 px-4 pt-4 h-36">
<a href="https://castopod.org/" class="absolute top-0 right-0 mt-1 mr-2 text-2xl text-pine-500 hover:opacity-75" title="<?= lang('Common.powered_by', [
'castopod' => 'Castopod',

View File

@ -10,7 +10,17 @@
<link rel="icon" type="image/x-icon" href="<?= service('settings')
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<link rel="manifest" href="<?= route_to('podcast-webmanifest', $podcast->handle) ?>">
<meta name="theme-color" content="<?= \App\Controllers\WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'] ?>">
<script>
// Check that service workers are supported
if ('serviceWorker' in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
<?= $metatags ?>
@ -70,7 +80,7 @@
<div class="absolute top-0 left-0 w-full h-full bg-center bg-no-repeat bg-cover blur-lg mix-blend-overlay filter grayscale" style="background-image: url('<?= $episode->podcast->banner->small_url ?>');"></div>
<div class="absolute top-0 left-0 w-full h-full bg-gradient-to-t from-background-header to-transparent"></div>
<div class="z-10 flex flex-col items-start gap-y-2 gap-x-4 sm:flex-row">
<img src="<?= $episode->cover->medium_url ?>" alt="<?= $episode->title ?>" loading="lazy" class="rounded-md shadow-xl h-36" />
<img src="<?= $episode->cover->medium_url ?>" alt="<?= $episode->title ?>" loading="lazy" class="rounded-md shadow-xl h-36 aspect-square" />
<div class="flex flex-col items-start text-white">
<?= episode_numbering($episode->number, $episode->season_number, 'text-sm leading-none font-semibold px-1 py-1 text-white/90 border !no-underline border-subtle', true) ?>
<h1 class="inline-flex items-baseline max-w-md mt-2 text-2xl font-bold sm:leading-none sm:text-3xl font-display line-clamp-2"><?= $episode->title ?></h1>
@ -80,7 +90,7 @@
<div class="inline-flex flex-row-reverse">
<?php $i = 0; ?>
<?php foreach ($episode->persons as $person): ?>
<img src="<?= $person->avatar->thumbnail_url ?>" alt="<?= $person->full_name ?>" class="object-cover w-8 h-8 -ml-5 border-2 rounded-full border-background-header last:ml-0" />
<img src="<?= $person->avatar->thumbnail_url ?>" alt="<?= $person->full_name ?>" class="object-cover w-8 h-8 -ml-5 border-2 rounded-full aspect-square border-background-header last:ml-0" />
<?php $i++; if ($i === 3) {
break;
}?>

View File

@ -4,7 +4,7 @@
<?= format_duration($episode->audio_file_duration) ?>
</time>
<img loading="lazy" src="<?= $episode->cover
->thumbnail_url ?>" alt="<?= $episode->title ?>" class="object-cover w-20 h-20 rounded-lg" />
->thumbnail_url ?>" alt="<?= $episode->title ?>" class="object-cover w-20 rounded-lg aspect-square" />
</div>
<div class="flex items-center flex-1 gap-x-4">
<div class="flex flex-col flex-1">

View File

@ -1,5 +1,5 @@
<article class="relative z-10 flex w-full px-4 py-2 rounded-conditional-2xl gap-x-2">
<img src="<?= $comment->actor->avatar_image_url ?>" alt="<?= $comment->display_name ?>" class="w-10 h-10 rounded-full" />
<img src="<?= $comment->actor->avatar_image_url ?>" alt="<?= $comment->display_name ?>" class="w-10 h-10 rounded-full aspect-square" />
<div class="flex-1">
<header class="w-full mb-2 text-sm">
<a href="<?= $comment->actor
@ -8,7 +8,7 @@
: 'target="_blank" rel="noopener noreferrer"' ?>>
<span class="mr-2 font-semibold truncate"><?= $comment->actor
->display_name ?></span>
<span class="text-sm text-skin-muted truncate">@<?= $comment->actor
<span class="text-sm truncate text-skin-muted">@<?= $comment->actor
->username .
($comment->actor->is_local
? ''

View File

@ -1,5 +1,5 @@
<article class="relative z-10 flex w-full p-4 shadow bg-elevated rounded-conditional-2xl gap-x-2">
<img src="<?= $comment->actor->avatar_image_url ?>" alt="<?= $comment->display_name ?>" class="w-10 h-10 rounded-full" />
<img src="<?= $comment->actor->avatar_image_url ?>" alt="<?= $comment->display_name ?>" class="w-10 h-10 rounded-full aspect-square" />
<div class="flex-1">
<header class="w-full mb-2 text-sm">
<a href="<?= $comment->actor->uri ?>" class="flex items-baseline hover:underline" <?= $comment->actor->is_local

View File

@ -1,6 +1,6 @@
<article class="flex px-6 py-4 bg-base gap-x-2">
<img src="<?= $reply->actor->avatar_image_url ?>" alt="<?= $reply->actor
->display_name ?>" class="z-10 w-10 h-10 rounded-full ring-gray-50 ring-2" />
->display_name ?>" class="z-10 w-10 h-10 rounded-full ring-gray-50 ring-2 aspect-square" />
<div class="flex flex-col flex-1 min-w-0">
<header class="flex items-center mb-2">
<a href="<?= $reply->actor

View File

@ -15,7 +15,7 @@ if ($comment->in_reply_to_id): ?>
<form action="<?= route_to('comment-attempt-reply', $podcast->id, $episode->id, $comment->id) ?>" method="POST" class="flex px-6 pt-8 pb-4 gap-x-2 bg-base">
<img src="<?= interact_as_actor()
->avatar_image_url ?>" alt="<?= interact_as_actor()
->display_name ?>" class="w-10 h-10 rounded-full" />
->display_name ?>" class="w-10 h-10 rounded-full aspect-square" />
<div class="flex flex-col flex-1">
<Forms.Textarea
name="message"

View File

@ -18,5 +18,5 @@ $navigationItems = [
<?php $isActive = url_is($item['uri']); ?>
<a href="<?= $item['uri'] ?>" class="px-4 py-1 text-sm font-semibold uppercase focus:ring-accent border-b-4<?= $isActive ? ' border-b-4 text-accent-base border-accent-base' : ' text-skin-muted hover:text-skin-base hover:border-subtle border-transparent' ?>"><?= $item['label'] ?><span class="px-2 ml-1 font-semibold rounded-full bg-base"><?= $item['labelInfo'] ?></span></a>
<?php endforeach; ?>
<button type="button" class="p-2 ml-auto rotate-180 rounded-full md:hidden focus:ring-accent" data-sidebar-toggler="toggler"><?= icon('menu') ?></button>
<button type="button" class="p-2 ml-auto rotate-180 rounded-full md:hidden focus:ring-accent" data-sidebar-toggler="toggler" aria-label="<?= lang('Navigation.toggle_sidebar') ?>"><?= icon('menu') ?></button>
</nav>

View File

@ -5,7 +5,7 @@
</time>
<img
src="<?= $episode->cover->thumbnail_url ?>"
alt="<?= $episode->title ?>" class="w-24 h-24"/>
alt="<?= $episode->title ?>" class="w-24 h-24 aspect-square"/>
</div>
<div class="flex flex-col flex-1 px-4 py-2">
<div class="inline-flex">

View File

@ -9,7 +9,7 @@
<img src="<?= interact_as_actor()
->avatar_image_url ?>" alt="<?= interact_as_actor()
->display_name ?>" class="w-10 h-10 rounded-full" />
->display_name ?>" class="w-10 h-10 rounded-full aspect-square" />
<div class="flex flex-col flex-1 min-w-0 gap-y-2">
<input name="episode_url" value="<?= $episode->link ?>" type="hidden" />
<Forms.Textarea

View File

@ -9,7 +9,7 @@
<img src="<?= interact_as_actor()
->avatar_image_url ?>" alt="<?= interact_as_actor()
->display_name ?>" class="w-10 h-10 rounded-full" />
->display_name ?>" class="w-10 h-10 rounded-full aspect-square" />
<div class="flex flex-col flex-1 min-w-0 gap-y-2">
<Forms.Textarea
name="message"

View File

@ -14,6 +14,16 @@
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<meta name="theme-color" content="<?= \App\Controllers\WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'] ?>">
<script>
// Check that service workers are supported
if ('serviceWorker' in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
<?= $metatags ?>
@ -50,7 +60,7 @@
<article class="text-white">
<div class="absolute bottom-0 left-0 z-10 w-full h-full backdrop-gradient"></div>
<div class="w-full h-full overflow-hidden">
<img alt="<?= $podcast->title ?>" src="<?= $podcast->cover->medium_url ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform group-focus:scale-105 group-hover:scale-105" />
<img alt="<?= $podcast->title ?>" src="<?= $podcast->cover->medium_url ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform aspect-square group-focus:scale-105 group-hover:scale-105" />
</div>
<div class="absolute bottom-0 left-0 z-20 px-4 pb-2">
<h2 class="font-bold leading-none truncate font-display"><?= $podcast->title ?></h2>

View File

@ -10,6 +10,16 @@
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<meta name="theme-color" content="<?= \App\Controllers\WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'] ?>">
<script>
// Check that service workers are supported
if ('serviceWorker' in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
<?= $metatags ?>

View File

@ -17,7 +17,7 @@
<div class="flex mt-2 mb-2">
<img src="<?= $persons['thumbnail_url'] ?>" alt="<?= $persons[
'full_name'
] ?>" class="object-cover w-16 h-16 rounded-full md:h-24 md:w-24 border-gray" />
] ?>" class="object-cover w-16 rounded-full aspect-square md:h-24 md:w-24 border-gray" />
<div class="flex flex-col ml-3 mr-4">
<span class="text-lg font-semibold text-skin-muted md:text-xl">
<?= $persons['full_name'] ?>

View File

@ -15,6 +15,16 @@
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<meta name="theme-color" content="<?= \App\Controllers\WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'] ?>">
<script>
// Check that service workers are supported
if ('serviceWorker' in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
<?= service('vite')
->asset('styles/index.css', 'css') ?>

View File

@ -10,7 +10,17 @@
<link rel="icon" type="image/x-icon" href="<?= service('settings')
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<link rel="manifest" href="<?= route_to('podcast-webmanifest', $podcast->handle) ?>">
<meta name="theme-color" content="<?= \App\Controllers\WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'] ?>">
<script>
// Check that service workers are supported
if ('serviceWorker' in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
<?= $metatags ?>
@ -35,7 +45,7 @@
<header class="relative z-50 flex flex-col-reverse justify-between w-full col-start-2 bg-top bg-no-repeat bg-cover sm:flex-row sm:items-end bg-header aspect-[3/1]" style="background-image: url('<?= $podcast->banner->medium_url ?>');">
<div class="absolute bottom-0 left-0 w-full h-full backdrop-gradient"></div>
<div class="z-10 flex items-center pl-4 -mb-6 md:pl-8 md:-mb-8 gap-x-4">
<img src="<?= $podcast->cover->thumbnail_url ?>" alt="<?= $podcast->title ?>" loading="lazy" class="h-24 rounded-full sm:h-28 md:h-36 ring-3 ring-background-elevated" />
<img src="<?= $podcast->cover->thumbnail_url ?>" alt="<?= $podcast->title ?>" loading="lazy" class="h-24 rounded-full sm:h-28 md:h-36 ring-3 ring-background-elevated aspect-square" />
<div class="relative flex flex-col text-white -top-2 sm:top-0 md:top-2">
<h1 class="text-lg font-bold leading-none line-clamp-2 md:leading-none md:text-2xl font-display"><?= $podcast->title ?><span class="ml-1 font-sans text-base font-normal">@<?= $podcast->handle ?></span></h1>
<span class="text-xs"><?= lang('Podcast.followers', [

View File

@ -20,5 +20,5 @@ $navigationItems = [
<?php $isActive = url_is($item['uri']); ?>
<a href="<?= $item['uri'] ?>" class="px-4 py-1 text-sm font-semibold uppercase focus:ring-accent border-b-4<?= $isActive ? ' border-b-4 text-accent-base border-accent-base' : ' text-skin-muted hover:text-skin-base hover:border-subtle border-transparent' ?>"><?= $item['label'] ?></a>
<?php endforeach; ?>
<button type="button" class="p-2 ml-auto rotate-180 rounded-full md:hidden focus:ring-accent" data-sidebar-toggler="toggler"><?= icon('menu') ?></button>
<button type="button" class="p-2 ml-auto rotate-180 rounded-full md:hidden focus:ring-accent" data-sidebar-toggler="toggler" aria-label="<?= lang('Navigation.toggle_sidebar') ?>"><?= icon('menu') ?></button>
</nav>

View File

@ -25,7 +25,7 @@
<div class="inline-flex flex-row-reverse">
<?php $i = 0; ?>
<?php foreach ($podcast->persons as $person): ?>
<img src="<?= $person->avatar->thumbnail_url ?>" alt="<?= $person->full_name ?>" class="object-cover w-8 h-8 -ml-5 border-2 rounded-full border-background-base last:ml-0" />
<img src="<?= $person->avatar->thumbnail_url ?>" alt="<?= $person->full_name ?>" class="object-cover w-8 -ml-5 border-2 rounded-full aspect-square border-background-base last:ml-0" />
<?php $i++; if ($i === 3) {
break;
}?>

View File

@ -10,7 +10,7 @@
<img src="<?= interact_as_actor()
->avatar_image_url ?>" alt="<?= interact_as_actor()
->display_name ?>" class="w-10 h-10 rounded-full" />
->display_name ?>" class="w-10 h-10 rounded-full aspect-square" />
<div class="flex flex-col flex-1 min-w-0 gap-y-2">
<Forms.Textarea
name="message"

View File

@ -10,8 +10,18 @@
<link rel="icon" type="image/x-icon" href="<?= service('settings')
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<link rel="manifest" href="<?= route_to('podcast-webmanifest', $actor->podcast->handle) ?>">
<meta name="theme-color" content="<?= \App\Controllers\WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'] ?>">
<script>
// Check that service workers are supported
if ('serviceWorker' in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
<?= $metatags ?>
<?= service('vite')
@ -27,10 +37,10 @@
'Fediverse.follow.subtitle',
) ?></h1>
<div class="flex flex-col w-full max-w-xs -mt-24 overflow-hidden shadow bg-elevated rounded-xl">
<img src="<?= $actor->podcast->banner->small_url ?>" alt="" class="object-cover w-full h-32 bg-header" />
<img src="<?= $actor->podcast->banner->small_url ?>" alt="" class="object-cover w-full aspect-[3/1] bg-header" />
<div class="flex px-4 py-2">
<img src="<?= $actor->avatar_image_url ?>" alt="<?= $actor->display_name ?>"
class="w-16 h-16 mr-4 -mt-8 rounded-full ring-2 ring-background-elevated" />
class="w-16 h-16 mr-4 -mt-8 rounded-full ring-2 ring-background-elevated aspect-square" />
<div class="flex flex-col">
<p class="font-semibold"><?= $actor->display_name ?></p>
<p class="text-sm text-skin-muted">@<?= $actor->username ?></p>

View File

@ -1,7 +1,7 @@
<article class="relative z-10 w-full shadow bg-elevated sm:rounded-conditional-2xl">
<header class="flex px-6 py-4 gap-x-2">
<img src="<?= $post->actor
->avatar_image_url ?>" alt="<?= $post->actor->display_name ?>" class="w-10 h-10 rounded-full" />
->avatar_image_url ?>" alt="<?= $post->actor->display_name ?>" class="w-10 h-10 rounded-full aspect-square" />
<div class="flex flex-col min-w-0">
<a href="<?= $post->actor
->uri ?>" class="flex items-baseline hover:underline" <?= $post

View File

@ -20,7 +20,7 @@ if ($post->in_reply_to_id): ?>
<form action="<?= route_to('post-attempt-action', interact_as_actor()->username, $post->id) ?>" method="POST" class="flex gap-x-2" >
<img src="<?= interact_as_actor()
->avatar_image_url ?>" alt="<?= interact_as_actor()
->display_name ?>" class="w-10 h-10 rounded-full" />
->display_name ?>" class="w-10 h-10 rounded-full aspect-square" />
<div class="flex flex-col flex-1">
<Forms.Textarea
name="message"

View File

@ -8,7 +8,7 @@ if ($preview_card->type === 'image'): ?>
'external-link',
'absolute inset-0 m-auto text-6xl bg-accent-base bg-opacity-50 group-hover:bg-opacity-100 text-accent-contrast rounded-full p-2',
) ?>
<img src="<?= $preview_card->image ?>" alt="<?= $preview_card->title ?>" class="object-cover w-full h-80" />
<img src="<?= $preview_card->image ?>" alt="<?= $preview_card->title ?>" class="object-cover w-full aspect-video" />
</div>
<?php endif; ?>
@ -36,7 +36,7 @@ if ($preview_card->type === 'image'): ?>
<?php else: ?>
<a href="<?= $preview_card->url ?>" class="flex items-center bg-highlight">
<?php if ($preview_card->image): ?>
<img src="<?= $preview_card->image ?>" alt="<?= $preview_card->title ?>" class="object-cover w-20 h-20" />
<img src="<?= $preview_card->image ?>" alt="<?= $preview_card->title ?>" class="object-cover w-20 aspect-square" />
<?php endif; ?>
<p class="flex flex-col flex-1 px-4 py-2">
<span class="text-xs tracking-wider uppercase text-skin-muted"><?= $preview_card->provider_name ?></span>

View File

@ -8,7 +8,7 @@
]) ?></p>
<header class="flex px-6 py-4 gap-x-2">
<img src="<?= $post->actor
->avatar_image_url ?>" alt="<?= $post->display_name ?>" class="w-10 h-10 rounded-full" />
->avatar_image_url ?>" alt="<?= $post->display_name ?>" class="w-10 h-10 rounded-full aspect-square" />
<div class="flex flex-col min-w-0">
<a href="<?= $post->actor
->uri ?>" class="flex items-baseline hover:underline" <?= $post

View File

@ -1,6 +1,6 @@
<article class="flex px-6 py-4 bg-base gap-x-2">
<img src="<?= $reply->actor->avatar_image_url ?>" alt="<?= $reply->actor
->display_name ?>" class="z-10 w-10 h-10 rounded-full ring-background-base ring-2" />
->display_name ?>" class="z-10 w-10 h-10 rounded-full ring-background-base ring-2 aspect-square" />
<div class="flex flex-col flex-1 min-w-0">
<header class="flex items-center mb-2">
<a href="<?= $reply->actor

View File

@ -8,7 +8,17 @@
<link rel="icon" type="image/x-icon" href="<?= service('settings')
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<link rel="manifest" href="<?= route_to('podcast-webmanifest', $post->actor->podcast->handle) ?>">
<meta name="theme-color" content="<?= \App\Controllers\WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'] ?>">
<script>
// Check that service workers are supported
if ('serviceWorker' in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
<?= $metatags ?>

View File

@ -11,7 +11,6 @@
<link rel="icon" type="image/x-icon" href="<?= service('settings')
->get('App.siteIcon')['ico'] ?>" />
<link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>">
<link rel="manifest" href="<?= route_to('webmanifest') ?>">
<?= service('vite')
->asset('styles/index.css', 'css') ?>
<?= service('vite')

View File

@ -1,4 +1,5 @@
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import { ManifestCSS } from "./vite-manifest-css";
// https://vitejs.dev/config/
@ -24,5 +25,11 @@ export default defineConfig({
},
},
},
plugins: [ManifestCSS()],
plugins: [
ManifestCSS(),
VitePWA({
manifest: false,
outDir: "../../public",
}),
],
});