feat(*): refactor dynamic vs. static structure and distinctions; make additional elements dynamic

This commit is contained in:
Cory Dransfeldt 2025-06-13 16:31:36 -07:00
parent 7a0b808f24
commit 4d76a3ba1e
No known key found for this signature in database
138 changed files with 998 additions and 970 deletions

22
app/Utils/icons.php Normal file
View file

@ -0,0 +1,22 @@
<?php
if (!function_exists('getTablerIcon')) {
function getTablerIcon($iconName, $class = '', $size = 24)
{
$icons = [
'arrow-left' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-left"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l14 0" /><path d="M5 12l6 6" /><path d="M5 12l6 -6" /></svg>',
'arrow-right' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l14 0" /><path d="M13 18l6 -6" /><path d="M13 6l6 6" /></svg>',
'article' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-article"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 4m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M7 8h10" /><path d="M7 12h10" /><path d="M7 16h10" /></svg>',
'books' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-books"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 4m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" /><path d="M9 4m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" /><path d="M5 8h4" /><path d="M9 16h4" /><path d="M13.803 4.56l2.184 -.53c.562 -.135 1.133 .19 1.282 .732l3.695 13.418a1.02 1.02 0 0 1 -.634 1.219l-.133 .041l-2.184 .53c-.562 .135 -1.133 -.19 -1.282 -.732l-3.695 -13.418a1.02 1.02 0 0 1 .634 -1.219l.133 -.041z" /><path d="M14 9l4 -1" /><path d="M16 16l3.923 -.98" /></svg>',
'device-tv-old' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-device-tv-old"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M16 3l-4 4l-4 -4" /><path d="M15 7v13" /><path d="M18 15v.01" /><path d="M18 12v.01" /></svg>',
'headphones' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-headphones"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 13m0 2a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-1a2 2 0 0 1 -2 -2z" /><path d="M15 13m0 2a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-1a2 2 0 0 1 -2 -2z" /><path d="M4 15v-3a8 8 0 0 1 16 0v3" /></svg>',
'movie' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-movie"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" /><path d="M8 4l0 16" /><path d="M16 4l0 16" /><path d="M4 8l4 0" /><path d="M4 16l4 0" /><path d="M4 12l16 0" /><path d="M16 8l4 0" /><path d="M16 16l4 0" /></svg>',
'star' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-star"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873z" /></svg>',
'vinyl' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-vinyl"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M16 3.937a9 9 0 1 0 5 8.063" /><path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M20 4m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M20 4l-3.5 10l-2.5 2" /></svg>'
];
return $icons[$iconName] ?? '<span class="icon-placeholder">[Missing: ' . htmlspecialchars($iconName) . ']</span>';
}
}
?>

8
app/Utils/init.php Normal file
View file

@ -0,0 +1,8 @@
<?php
require_once __DIR__ . '/icons.php';
require_once __DIR__ . '/media.php';
require_once __DIR__ . '/metadata.php';
require_once __DIR__ . '/paginator.php';
require_once __DIR__ . '/routing.php';
require_once __DIR__ . '/strings.php';
require_once __DIR__ . '/tags.php';

147
app/Utils/media.php Normal file
View file

@ -0,0 +1,147 @@
<?php
if (!function_exists('renderMediaGrid')) {
function renderMediaGrid(array $items, int $count = 0, string $loading = 'lazy')
{
$limit = $count > 0 ? $count : count($items);
$firstType = $items[0]['type'] ?? ($items[0]['grid']['type'] ?? '');
$shapeClass = in_array($firstType, ['books', 'movies', 'tv']) ? 'vertical' : 'square';
echo '<div class="media-grid ' . $shapeClass . '">';
foreach (array_slice($items, 0, $limit) as $item) {
$grid = $item['grid'] ?? $item;
$alt = htmlspecialchars($grid['alt'] ?? '');
$image = htmlspecialchars($grid['image'] ?? '');
$title = htmlspecialchars($grid['title'] ?? '');
$subtext = htmlspecialchars($grid['subtext'] ?? '');
$url = $grid['url'] ?? null;
$type = $item['type'] ?? '';
$isVertical = in_array($type, ['books', 'movies', 'tv']);
$imageClass = $isVertical ? 'vertical' : 'square';
$width = $isVertical ? 120 : 150;
$height = $isVertical ? 184 : 150;
$openLink = $url ? '<a class="' . htmlspecialchars($type) . '" href="' . htmlspecialchars($url) . '" title="' . $alt . '">' : '';
$closeLink = $url ? '</a>' : '';
echo $openLink;
echo '<div class="media-grid-item">';
if ($title || $subtext) {
echo '<div class="meta-text media-highlight">';
if ($title) echo '<div class="header">' . $title . '</div>';
if ($subtext) echo '<div class="subheader">' . $subtext . '</div>';
echo '</div>';
}
echo '<img
srcset="' . $image . '?class=' . $imageClass . 'sm&type=webp ' . $width . 'w, ' .
$image . '?class=' . $imageClass . 'md&type=webp ' . ($width * 2) . 'w"
sizes="(max-width: 450px) ' . $width . 'px, ' . ($width * 2) . 'px"
src="' . $image . '?class=' . $imageClass . 'sm&type=webp"
alt="' . $alt . '"
loading="' . $loading . '"
decoding="async"
width="' . $width . '"
height="' . $height . '"
>';
echo '</div>';
echo $closeLink;
}
echo '</div>';
}
}
if (!function_exists('renderAssociatedMedia')) {
function renderAssociatedMedia(
array $artists = [],
array $books = [],
array $genres = [],
array $movies = [],
array $posts = [],
array $shows = [],
)
{
$sections = [
"artists" => ["icon" => "headphones", "css_class" => "music", "label" => "Related artist(s)", "hasGrid" => true],
"books" => ["icon" => "books", "css_class" => "books", "label" => "Related book(s)", "hasGrid" => true],
"genres" => ["icon" => "headphones", "css_class" => "music", "label" => "Related genre(s)", "hasGrid" => false],
"movies" => ["icon" => "movie", "css_class" => "movies", "label" => "Related movie(s)", "hasGrid" => true],
"posts" => ["icon" => "article", "css_class" => "article", "label" => "Related post(s)", "hasGrid" => false],
"shows" => ["icon" => "device-tv-old", "css_class" => "tv", "label" => "Related show(s)", "hasGrid" => true]
];
$allMedia = compact('artists', 'books', 'genres', 'movies', 'posts', 'shows');
echo '<div class="associated-media">';
foreach ($sections as $key => $section) {
$items = $allMedia[$key];
if (empty($items)) continue;
echo '<h3 id="' . htmlspecialchars($key) . '" class="' . htmlspecialchars($section['css_class']) . '">';
echo getTablerIcon($section['icon']);
echo htmlspecialchars($section['label']);
echo '</h3>';
if ($section['hasGrid']) {
renderMediaGrid($items);
} else {
echo '<ul>';
foreach ($items as $item) {
echo '<li class="' . htmlspecialchars($section['css_class']) . '">';
echo '<a href="' . htmlspecialchars($item['url']) . '">' . htmlspecialchars($item['title'] ?? $item['name'] ?? '') . '</a>';
if ($key === "artists" && isset($item['total_plays']) && $item['total_plays'] > 0) {
echo ' (' . htmlspecialchars($item['total_plays']) . ' play' . ($item['total_plays'] > 1 ? 's' : '') . ')';
} elseif ($key === "books" && isset($item['author'])) {
echo ' by ' . htmlspecialchars($item['author']);
} elseif (($key === "movies" || $key === "shows") && isset($item['year'])) {
echo ' (' . htmlspecialchars($item['year']) . ')';
} elseif ($key === "posts" && isset($item['date'])) {
echo ' (' . date("F j, Y", strtotime($item['date'])) . ')';
}
echo '</li>';
}
echo '</ul>';
}
}
echo '</div>';
}
}
if (!function_exists('renderMediaLinks')) {
function renderMediaLinks(array $data, string $type, int $count = 10): string {
if (empty($data) || empty($type)) return "";
$slice = array_slice($data, 0, $count);
if (count($slice) === 0) return "";
$buildLink = function ($item) use ($type) {
switch ($type) {
case "genre":
return '<a href="' . htmlspecialchars($item['genre_url']) . '">' . htmlspecialchars($item['genre_name']) . '</a>';
case "artist":
return '<a href="' . htmlspecialchars($item['url']) . '">' . htmlspecialchars($item['name']) . '</a>';
case "book":
return '<a href="' . htmlspecialchars($item['url']) . '">' . htmlspecialchars($item['title']) . '</a>';
default:
return '';
}
};
if (count($slice) === 1) return $buildLink($slice[0]);
$links = array_map($buildLink, $slice);
$last = array_pop($links);
return implode(', ', $links) . ' and ' . $last;
}
}
?>

39
app/Utils/metadata.php Normal file
View file

@ -0,0 +1,39 @@
<?php
if (!function_exists('setupPageMetadata')) {
function setupPageMetadata(array $page, string $requestUri): array
{
$globals = $page['globals'] ?? [];
$title = htmlspecialchars($page["metadata"]["title"] ?? "", ENT_QUOTES, "UTF-8");
$description = htmlspecialchars($page["metadata"]["description"] ?? "", ENT_QUOTES, "UTF-8");
$image = htmlspecialchars(
$page["metadata"]["open_graph_image"] ?? $globals["metadata"]["open_graph_image"] ?? "",
ENT_QUOTES,
"UTF-8"
);
$fullUrl = $globals["url"] . $requestUri;
$oembedUrl = $globals["url"] . "/oembed" . $requestUri;
return [
'pageTitle' => $title,
'pageDescription' => $description,
'ogImage' => $image,
'fullUrl' => $fullUrl,
'oembedUrl' => $oembedUrl,
'globals' => $globals
];
}
}
if (!function_exists('cleanMeta')) {
function cleanMeta($value)
{
$value = trim($value ?? '');
$value = str_replace(["\r", "\n"], ' ', $value);
$value = preg_replace('/\s+/', ' ', $value);
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
}
?>

41
app/Utils/paginator.php Normal file
View file

@ -0,0 +1,41 @@
<?php
if (!function_exists('renderPaginator')) {
function renderPaginator(array $pagination, int $totalPages): void
{
if (!$pagination || $totalPages <= 1) return;
?>
<script type="module" src="/assets/scripts/components/select-pagination.js" defer></script>
<nav aria-label="Pagination" class="pagination">
<?php if (!empty($pagination['href']['previous'])): ?>
<a href="<?= $pagination['href']['previous'] ?>" aria-label="Previous page">
<?= getTablerIcon('arrow-left') ?>
</a>
<?php else: ?>
<span><?= getTablerIcon('arrow-left') ?></span>
<?php endif; ?>
<select-pagination data-base-index="1">
<select class="client-side" aria-label="Page selection">
<?php foreach ($pagination['pages'] as $i): ?>
<option value="<?= $i ?>" <?= ($pagination['pageNumber'] === $i) ? 'selected' : '' ?>>
<?= $i ?> of <?= $totalPages ?>
</option>
<?php endforeach; ?>
</select>
<noscript>
<p><span aria-current="page"><?= $pagination['pageNumber'] ?></span> of <?= $totalPages ?></p>
</noscript>
</select-pagination>
<?php if (!empty($pagination['href']['next'])): ?>
<a href="<?= $pagination['href']['next'] ?>" aria-label="Next page">
<?= getTablerIcon('arrow-right') ?>
</a>
<?php else: ?>
<span><?= getTablerIcon('arrow-right') ?></span>
<?php endif; ?>
</nav>
<?php
}
}

11
app/Utils/routing.php Normal file
View file

@ -0,0 +1,11 @@
<?php
if (!function_exists('redirectTo404')) {
function redirectTo404(): void
{
header("Location: /404/", true, 302);
exit();
}
}
?>

84
app/Utils/strings.php Normal file
View file

@ -0,0 +1,84 @@
<?php
use Kaoken\MarkdownIt\MarkdownIt;
use Kaoken\MarkdownIt\Plugins\MarkdownItFootnote;
if (!function_exists('truncateText')) {
function truncateText($text, $limit = 50, $ellipsis = "...")
{
if (mb_strwidth($text, "UTF-8") <= $limit) return $text;
$truncated = mb_substr($text, 0, $limit, "UTF-8");
$lastSpace = mb_strrpos($truncated, " ", 0, "UTF-8");
if ($lastSpace !== false) $truncated = mb_substr($truncated, 0, $lastSpace, "UTF-8");
return $truncated . $ellipsis;
}
}
if (!function_exists('parseMarkdown')) {
function parseMarkdown($markdown)
{
if (empty($markdown)) return '';
$md = new MarkdownIt([
"html" => true,
"linkify" => true,
]);
$md->plugin(new MarkdownItFootnote());
return $md->render($markdown);
}
}
if (!function_exists('parseCountryField')) {
function parseCountryField($countryField)
{
if (empty($countryField)) return null;
$delimiters = [',', '/', '&', ' and '];
$countries = [$countryField];
foreach ($delimiters as $delimiter) {
$tempCountries = [];
foreach ($countries as $country) {
$tempCountries = array_merge($tempCountries, explode($delimiter, $country));
}
$countries = $tempCountries;
}
$countries = array_map('trim', $countries);
$countries = array_map('getCountryName', $countries);
$countries = array_filter($countries);
return implode(', ', array_unique($countries));
}
}
if (!function_exists('getCountryName')) {
function getCountryName($countryName)
{
$isoCodes = new \Sokil\IsoCodes\IsoCodesFactory();
$countries = $isoCodes->getCountries();
$country = $countries->getByAlpha2($countryName);
if ($country) return $country->getName();
return ucfirst(strtolower($countryName));
}
}
if (!function_exists('pluralize')) {
function pluralize($count, $string, $trailing = '')
{
if ((int)$count === 1) return $string;
return $string . 's' . ($trailing ? $trailing : '');
}
}
?>

17
app/Utils/tags.php Normal file
View file

@ -0,0 +1,17 @@
<?php
if (!function_exists('renderTags')) {
function renderTags(array $tags): void
{
if (empty($tags)) return;
echo '<div class="tags">';
foreach ($tags as $tag) {
$slug = strtolower(trim($tag));
echo '<a href="/tags/' . rawurlencode($slug) . '">#' . htmlspecialchars($slug) . '</a>';
}
echo '</div>';
}
}