movmaker-webui/index.php

721 lines
34 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
date_default_timezone_set('Europe/Copenhagen');
$config = require __DIR__ . "/config.php";
foreach (["videos_dir", "thumbs_dir", "uploads_dir"] as $d) if (!is_dir($config[$d])) mkdir($config[$d], 0775, true);
if (!is_dir(__DIR__ . "/cache")) mkdir(__DIR__ . "/cache", 0775, true);
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, "UTF-8"); }
function render_description_html($s){
$html = h($s);
$linked = preg_replace_callback('/(?<![A-Za-z0-9_])(subscribe|contact|journal|postcard)(?![A-Za-z0-9_])/i', static function($m){
return '<a class="description-contact-link" href="/contact.php" target="_blank" rel="noopener noreferrer">' . $m[0] . '<span class="external-link-icon" aria-hidden="true">↗</span><span class="sr-only"> (opens in new tab)</span></a>';
}, $html);
return nl2br($linked ?? $html, false);
}
function nice_title($s){ return trim(ucwords(str_replace(['_','-'], ' ', (string)$s))); }
function lower_text($s){ return function_exists('mb_strtolower') ? mb_strtolower((string)$s, 'UTF-8') : strtolower((string)$s); }
function fold_text($s){
$s = strtr((string)$s, [
'æ'=>'ae','Æ'=>'ae','ø'=>'o','Ø'=>'o','å'=>'aa','Å'=>'aa',
'ä'=>'ae','Ä'=>'ae','ö'=>'oe','Ö'=>'oe','ü'=>'ue','Ü'=>'ue','ß'=>'ss','ẞ'=>'ss',
'č'=>'c','Č'=>'c','ć'=>'c','Ć'=>'c','ž'=>'z','Ž'=>'z','š'=>'s','Š'=>'s','đ'=>'d','Đ'=>'d',
]);
if (function_exists('iconv')) {
$x = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
if ($x !== false) $s = $x;
}
$s = lower_text($s);
return preg_replace('/[^a-z0-9]+/', ' ', $s) ?? '';
}
function ci_contains($haystack, $needle){
$haystack = (string)$haystack;
$needle = (string)$needle;
if ($needle === '') return true;
if (function_exists('mb_stripos') && mb_stripos($haystack, $needle, 0, 'UTF-8') !== false) return true;
if (strpos(lower_text($haystack), lower_text($needle)) !== false) return true;
$foldHay = fold_text($haystack);
$foldNeedle = trim(fold_text($needle));
if ($foldNeedle === '') return false;
return strpos($foldHay, $foldNeedle) !== false;
}
function keep_non_empty(array $params): array { return array_filter($params, fn($v)=>$v !== '' && $v !== null); }
function query_url(array $params): string {
$params = keep_non_empty($params);
return $params ? ('/?' . http_build_query($params)) : '/';
}
function hidden_video_outputs($config){
$hidden = [];
foreach (glob($config['uploads_dir'].'/*', GLOB_ONLYDIR) ?: [] as $dir) {
if (!is_file($dir . '/.mvlog-hidden')) continue;
$state = is_file($dir . '/.movmaker-state.json') ? json_decode((string)file_get_contents($dir . '/.movmaker-state.json'), true) : [];
if (is_array($state) && !empty($state['output'])) $hidden[basename((string)$state['output'])] = true;
}
return $hidden;
}
function map_hidden_outputs($config){
$hidden = [];
foreach (glob($config['uploads_dir'].'/*', GLOB_ONLYDIR) ?: [] as $dir) {
if (!is_file($dir . '/.mvlog-hide-map')) continue;
$state = is_file($dir . '/.movmaker-state.json') ? json_decode((string)file_get_contents($dir . '/.movmaker-state.json'), true) : [];
if (is_array($state) && !empty($state['output'])) $hidden[basename((string)$state['output'])] = true;
}
return $hidden;
}
function shown_video_paths($config){
$videos = [];
$seen = [];
foreach (glob($config['uploads_dir'].'/*', GLOB_ONLYDIR) ?: [] as $dir) {
// Public index shows only items explicitly enabled with the admin "Show" switch.
if (is_file($dir . '/.mvlog-hidden')) continue;
$stateFile = $dir . '/.movmaker-state.json';
if (!is_file($stateFile)) continue;
$state = json_decode((string)file_get_contents($stateFile), true);
if (!is_array($state)) continue;
$name = basename((string)($state['output'] ?? ''));
if ($name === '' || preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', $name)) continue;
$path = $config['videos_dir'] . '/' . $name;
if (!is_file($path) || isset($seen[$name])) continue;
$seen[$name] = true;
$videos[] = $path;
}
return $videos;
}
function permalink_video_paths($config, $articleId){
$articleId = strtolower(trim((string)$articleId));
if (!preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $articleId)) return [];
foreach (glob($config['uploads_dir'].'/*', GLOB_ONLYDIR) ?: [] as $dir) {
if (!is_file($dir . '/.mvlog-permalink')) continue;
if (read_article_id($dir) !== $articleId) continue;
$stateFile = $dir . '/.movmaker-state.json';
if (!is_file($stateFile)) return [];
$state = json_decode((string)file_get_contents($stateFile), true);
if (!is_array($state)) return [];
$name = basename((string)($state['output'] ?? ''));
if ($name === '' || preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', $name)) return [];
$path = $config['videos_dir'] . '/' . $name;
return is_file($path) ? [$path] : [];
}
return [];
}
function public_url_path($path){
$path = str_replace('\\', '/', trim((string)$path));
if ($path === '') return '';
$parts = array_values(array_filter(explode('/', $path), static fn($p) => $p !== ''));
return '/' . implode('/', array_map('rawurlencode', $parts));
}
function video_thumb_public_url($config, $videoName){
$videoName = basename((string)$videoName);
if ($videoName === '') return '';
$info = function_exists('video_thumb_source_info') ? video_thumb_source_info($config, $videoName) : [];
$sourcePath = (string)($info['source_path'] ?? '');
if ($sourcePath !== '') {
$abs = str_starts_with($sourcePath, '/') ? $sourcePath : __DIR__ . '/' . ltrim($sourcePath, '/');
if (is_file($abs)) return public_url_path($sourcePath) . '?v=' . filemtime($abs);
}
$sourceFile = basename((string)($info['source_file'] ?? ''));
$inputDir = basename((string)($info['input_dir'] ?? ''));
if ($sourceFile !== '' && $inputDir !== '') {
$rel = 'in-dir/' . $inputDir . '/' . $sourceFile;
$abs = __DIR__ . '/' . $rel;
if (is_file($abs)) return public_url_path($rel) . '?v=' . filemtime($abs);
}
return '';
}
function video_thumb_source_info($config, $videoName){
$videoName = basename((string)$videoName);
if ($videoName === '') return [];
$base = pathinfo($videoName, PATHINFO_FILENAME);
$file = $config['videos_dir'] . '/' . $base . '_thumb.json';
if (!is_file($file)) return [];
$data = json_decode((string)file_get_contents($file), true);
return is_array($data) ? $data : [];
}
function video_article_id($config, $videoName){
$videoName = basename((string)$videoName);
if ($videoName === '') return '';
foreach (glob($config['uploads_dir'].'/*', GLOB_ONLYDIR) ?: [] as $dir) {
$stateFile = $dir . '/.movmaker-state.json';
if (!is_file($stateFile)) continue;
$state = json_decode((string)file_get_contents($stateFile), true);
if (!is_array($state)) continue;
$output = basename((string)($state['output'] ?? ''));
$previewOutput = basename((string)($state['preview_output'] ?? ''));
if ($videoName === $output || $videoName === $previewOutput) return (string)($state['article_id'] ?? '');
}
return '';
}
function input_dirs($base){ return glob($base.'/*', GLOB_ONLYDIR) ?: []; }
function read_article_id($dir){
$idFile = rtrim((string)$dir, '/').'/.mvlog-id';
if (!is_file($idFile)) return '';
$id = trim((string)file_get_contents($idFile));
return preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $id) ? $id : '';
}
function input_dir_permalink($dir){ return is_file($dir . '/.mvlog-permalink'); }
function article_permalink_enabled($config, $articleId){
static $cache = null;
if ($cache === null) {
$cache = [];
foreach (input_dirs($config['uploads_dir']) as $dir) {
$id = read_article_id($dir);
if ($id !== '') $cache[$id] = input_dir_permalink($dir);
}
}
$articleId = strtolower(trim((string)$articleId));
if (!preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $articleId)) return false;
return !empty($cache[$articleId]);
}
function permalink_slug_from_title($title, $fallback = 'post'){
$slug = strtr((string)$title, [
'æ'=>'ae','Æ'=>'ae','ø'=>'o','Ø'=>'o','å'=>'aa','Å'=>'aa',
'ä'=>'ae','Ä'=>'ae','ö'=>'oe','Ö'=>'oe','ü'=>'ue','Ü'=>'ue','ß'=>'ss','ẞ'=>'ss',
'č'=>'c','Č'=>'c','ć'=>'c','Ć'=>'c','ž'=>'z','Ž'=>'z','š'=>'s','Š'=>'s','đ'=>'d','Đ'=>'d',
]);
if (function_exists('iconv')) {
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $slug);
if ($ascii !== false) $slug = $ascii;
}
$slug = strtolower($slug);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
$slug = trim($slug, '-');
return $slug !== '' ? $slug : $fallback;
}
function permalink_title_for_dir($config, $dir, $videoCache = []){
$dataFile = rtrim((string)$dir, '/') . '/data.txt';
if (is_file($dataFile)) {
foreach (file($dataFile, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
if (!str_contains($line, ':')) continue;
[$key, $value] = array_map('trim', explode(':', $line, 2));
if (in_array(strtolower($key), ['title','name'], true) && $value !== '') return $value;
}
}
$stateFile = rtrim((string)$dir, '/') . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
$output = is_array($state) ? basename((string)($state['output'] ?? '')) : '';
if ($output !== '') {
$path = $config['videos_dir'] . '/' . $output;
if (is_file($path)) {
$meta = $videoCache[$output]['metadata'] ?? video_metadata($path);
$title = trim((string)($meta['title'] ?? ''));
if ($title !== '') return $title;
}
}
return nice_title(basename((string)$dir));
}
function permalink_slug_map($config, $videoCache = []){
$map = [];
$used = [];
$dirs = input_dirs($config['uploads_dir']);
sort($dirs, SORT_STRING);
foreach ($dirs as $dir) {
if (!input_dir_permalink($dir)) continue;
$id = read_article_id($dir);
if ($id === '') continue;
$base = permalink_slug_from_title(permalink_title_for_dir($config, $dir, $videoCache), $id);
$slug = $base;
if (isset($used[$slug])) $slug = $base . '-' . substr($id, -6);
$n = 2;
while (isset($used[$slug])) {
$slug = $base . '-' . substr($id, -6) . '-' . $n;
$n++;
}
$used[$slug] = true;
$map[$slug] = $id;
}
return $map;
}
function article_permalink_url($config, $articleId, $videoCache = [], $absolute = false){
$articleId = strtolower(trim((string)$articleId));
if (!preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $articleId)) return '';
foreach (permalink_slug_map($config, $videoCache) as $slug => $id) {
if ($id === $articleId) {
$path = '/post/' . rawurlencode($slug);
return $absolute ? ('https://bubulescu.org' . $path) : $path;
}
}
return '';
}
function article_id_for_permalink_slug($config, $slug, $videoCache = []){
$slug = strtolower(trim((string)$slug, "/ \t\n\r\0\x0B"));
if (!preg_match('/^[a-z0-9][a-z0-9-]*$/', $slug)) return '';
$map = permalink_slug_map($config, $videoCache);
return $map[$slug] ?? '';
}
function article_og_image_url($articleId){
$articleId = strtolower(trim((string)$articleId));
if (!preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $articleId)) return '';
$path = __DIR__ . '/out-dir/' . $articleId . '_og.jpg';
if (!is_file($path)) return '';
return 'https://bubulescu.org/out-dir/' . rawurlencode($articleId . '_og.jpg');
}
function render_404_page(){
http_response_code(404);
include __DIR__ . '/404.php';
exit;
}
function render_blank_page(){
$headerTitles = require __DIR__ . '/header_titles.php';
$siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
$brandUrl = '/';
$ogImageUrl = 'https://bubulescu.org/assets/img/bubulescuorg.jpg';
?>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>MVLog</title>
<?php include __DIR__ . '/_pwa_head.php'; ?>
<link rel="icon" type="image/png" href="/assets/img/moto_travel.png">
<link rel="stylesheet" href="/style.css?v=20260531u">
<style>html,body{height:100%;overflow:hidden}body{height:100dvh}main{flex:1;min-height:0}</style>
</head>
<body>
<?php include __DIR__ . '/_header.php'; ?>
<main></main>
<?php include __DIR__ . '/_footer.php'; ?>
</body>
</html>
<?php
exit;
}
function video_metadata($path){
$base = pathinfo($path, PATHINFO_FILENAME);
$meta = [
'title' => nice_title($base),
'teaser' => '',
'date' => date('Y-m-d', filemtime($path)),
'sort_date' => date('Y-m-d', filemtime($path)),
'location' => '',
'quote_da' => '',
'description' => ''
];
if (preg_match('/^(\d{4})(\d{2})(\d{2})[_-](.+)$/', $base, $m)) {
$meta['date'] = "$m[1]-$m[2]-$m[3]";
$meta['sort_date'] = "$m[1]-$m[2]-$m[3]";
$meta['title'] = nice_title($m[4]);
}
foreach ([dirname($path).'/'.$base.'.txt', dirname($path).'/'.$base.'.data.txt'] as $sidecar) {
if (!is_file($sidecar)) continue;
foreach (file($sidecar, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (!str_contains($line, ':')) continue;
[$k, $v] = array_map('trim', explode(':', $line, 2));
$k = strtolower($k);
if (in_array($k, ['title','teaser','date','location','place','description','quote_da'], true)) {
$target = ($k === 'place') ? 'location' : $k;
$meta[$target] = $target === 'quote_da' ? trim((string)$v, "\"“”") : $v;
}
}
}
$ffprobe = trim((string)shell_exec('command -v ffprobe 2>/dev/null'));
if ($ffprobe !== '') {
$json = shell_exec(escapeshellarg($ffprobe).' -v quiet -print_format json -show_entries format_tags '.escapeshellarg($path).' 2>/dev/null');
$data = json_decode((string)$json, true);
$tags = $data['format']['tags'] ?? [];
$lower = [];
foreach ($tags as $k => $v) $lower[strtolower((string)$k)] = $v;
if (!empty($lower['title'])) $meta['title'] = $lower['title'];
if (!empty($lower['teaser'])) $meta['teaser'] = $lower['teaser'];
if (!empty($lower['date'])) $meta['date'] = $lower['date'];
if (!empty($lower['creation_time'])) {
$meta['sort_date'] = substr((string)$lower['creation_time'], 0, 10);
if (empty($lower['date'])) $meta['date'] = $meta['sort_date'];
}
foreach (['location','place','location-eng','com.apple.quicktime.location.name','com.apple.quicktime.location.iso6709'] as $k) {
if (!empty($lower[$k])) { $meta['location'] = $lower[$k]; break; }
}
foreach (['description','synopsis'] as $k) {
if (!empty($lower[$k])) { $meta['description'] = $lower[$k]; break; }
}
if (!empty($lower['quote_da'])) {
$meta['quote_da'] = trim((string)$lower['quote_da'], "\"“”");
}
}
return $meta;
}
function load_video_cache($videos){
$cacheFile = __DIR__ . '/cache/videos.json';
$cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : [];
if (!is_array($cache) || ($cache['version'] ?? 0) !== 4) $cache = [];
$items = $cache['items'] ?? [];
$newItems = [];
$changed = false;
foreach ($videos as $path) {
$key = basename($path);
$mtime = filemtime($path);
$size = filesize($path);
$cached = $items[$key] ?? null;
if (!is_array($cached) || ($cached['mtime'] ?? null) !== $mtime || ($cached['size'] ?? null) !== $size) {
$cached = ['mtime' => $mtime, 'size' => $size, 'metadata' => video_metadata($path)];
$changed = true;
}
$newItems[$key] = $cached;
}
if (array_diff(array_keys($items), array_keys($newItems))) $changed = true;
if ($changed) {
file_put_contents($cacheFile, json_encode([
'version' => 4,
'generated_at' => date('c'),
'items' => $newItems
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
}
return $newItems;
}
$hiddenOutputs = hidden_video_outputs($config);
$hiddenMapOutputs = map_hidden_outputs($config);
$allVideos = shown_video_paths($config);
$videoCache = load_video_cache($allVideos);
usort($allVideos, function($a, $b) use ($videoCache) {
$am = $videoCache[basename($a)] ?? [];
$bm = $videoCache[basename($b)] ?? [];
$ad = strtotime((string)($am['metadata']['sort_date'] ?? $am['metadata']['date'] ?? '')) ?: filemtime($a);
$bd = strtotime((string)($bm['metadata']['sort_date'] ?? $bm['metadata']['date'] ?? '')) ?: filemtime($b);
return $bd <=> $ad;
});
$postSlug = trim((string)($_GET['post'] ?? ''));
$articleId = '';
if ($postSlug !== '') {
$articleId = article_id_for_permalink_slug($config, $postSlug, $videoCache);
if ($articleId === '') render_404_page();
} else {
$articleId = trim((string)($_GET['id'] ?? ''));
if ($articleId !== '' && !preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $articleId)) {
render_404_page();
}
if ($articleId !== '' && article_permalink_enabled($config, $articleId)) {
$canonicalPath = article_permalink_url($config, $articleId, $videoCache, false);
if ($canonicalPath !== '') {
header('Location: ' . $canonicalPath, true, 301);
exit;
}
}
}
if ($articleId !== '') {
if (!article_permalink_enabled($config, $articleId)) {
render_404_page();
}
// Permalink pages are allowed when Permalink is ON, even if Show is OFF.
$videos = permalink_video_paths($config, $articleId);
$rawKeyword = '';
$keywordType = 'general';
$keywordValue = '';
} else {
$videos = array_values(array_filter($allVideos, fn($v) => empty($hiddenOutputs[basename($v)])));
$rawKeyword = trim((string)($_GET['q'] ?? ''));
// Backward compatibility with older filter/query formats.
if ($rawKeyword === '') {
$legacyTitle = trim((string)($_GET['f_title'] ?? ''));
$legacyDate = trim((string)($_GET['f_date'] ?? ''));
$legacyLocation = trim((string)($_GET['f_location'] ?? ''));
$legacyJob = trim((string)($_GET['job'] ?? ''));
if ($legacyTitle !== '') $rawKeyword = $legacyTitle;
elseif ($legacyDate !== '') $rawKeyword = 'date=' . $legacyDate;
elseif ($legacyLocation !== '') $rawKeyword = 'location=' . $legacyLocation;
elseif ($legacyJob !== '') {
$legacy = preg_replace('/^\d{8}[_-]/', '', $legacyJob);
$rawKeyword = nice_title($legacy ?: $legacyJob);
}
}
$keywordType = 'general';
$keywordValue = $rawKeyword;
if ($rawKeyword !== '' && preg_match('/^\s*(date|location|place)\s*=\s*(.+)\s*$/i', $rawKeyword, $m)) {
$keywordType = lower_text((string)$m[1]);
if ($keywordType === 'place') $keywordType = 'location';
$keywordValue = trim((string)$m[2]);
}
$videos = array_values(array_filter($videos, function($v) use ($videoCache, $keywordType, $keywordValue) {
if ($keywordValue === '') return true;
$name = basename($v);
$m = $videoCache[$name]['metadata'] ?? video_metadata($v);
$title = (string)($m['title'] ?? '');
$teaser = (string)($m['teaser'] ?? '');
$description = (string)($m['description'] ?? '');
$quoteDa = (string)($m['quote_da'] ?? '');
$date = (string)($m['date'] ?? '');
$sortDate = (string)($m['sort_date'] ?? '');
$location = (string)($m['location'] ?? '');
if ($keywordType === 'date') {
return ci_contains($date, $keywordValue) || ci_contains($sortDate, $keywordValue);
}
if ($keywordType === 'location') {
return ci_contains($location, $keywordValue);
}
return ci_contains($title, $keywordValue) || ci_contains($teaser, $keywordValue) || ci_contains($quoteDa, $keywordValue) || ci_contains($description, $keywordValue);
}));
}
$page = max(1, (int)($_GET['page'] ?? 1));
$per = $config['items_per_page'];
$total = count($videos);
$pages = max(1, (int)ceil($total / $per));
$page = min($page, $pages);
$slice = array_slice($videos, ($page - 1) * $per, $per);
if (!$slice) {
if ($articleId === '') {
render_blank_page();
}
render_404_page();
}
$activeFilterCount = ($keywordValue !== '') ? 1 : 0;
$baseParams = ($rawKeyword !== '') ? ['q' => $rawKeyword] : [];
$defaultOgImageUrl = 'https://bubulescu.org/assets/img/bubulescuorg.jpg';
$ogImageUrl = $defaultOgImageUrl;
$ogImageAlt = 'journal of an unreliable narrator.';
$ogType = ($articleId !== '') ? 'article' : 'website';
$canonicalArticleUrl = ($articleId !== '') ? article_permalink_url($config, $articleId, $videoCache, true) : '';
$ogUrl = ($canonicalArticleUrl !== '') ? $canonicalArticleUrl : 'https://bubulescu.org/';
$pageTitle = 'MVLog';
if ($articleId !== '') {
$customOg = article_og_image_url($articleId);
if ($customOg !== '') {
$ogImageUrl = $customOg;
}
if (!empty($slice)) {
$firstVideo = basename((string)$slice[0]);
$firstMeta = $videoCache[$firstVideo]['metadata'] ?? video_metadata($slice[0]);
$firstTitle = trim((string)($firstMeta['title'] ?? ''));
if ($firstTitle !== '') {
$ogImageAlt = $firstTitle;
$pageTitle = $firstTitle;
}
}
}
$headerTitles = require __DIR__ . '/header_titles.php';
$siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
?>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title><?=h($pageTitle)?></title>
<link rel="canonical" href="<?=h($ogUrl)?>">
<?php include __DIR__ . '/_pwa_head.php'; ?>
<!-- Basic Open Graph -->
<meta property="og:type" content="<?=h($ogType)?>">
<meta property="og:title" content="<?=h($ogImageAlt)?>">
<meta property="og:description" content="Motorcycle road stories, hockey passion, pizza nerdism and facts remembered by an unreliable narrator.">
<meta property="og:url" content="<?=h($ogUrl)?>">
<meta property="og:site_name" content="Bubulescu.Org">
<!-- Image -->
<meta property="og:image" content="<?=h($ogImageUrl)?>">
<meta property="og:image:secure_url" content="<?=h($ogImageUrl)?>">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="<?=h($ogImageAlt)?>">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="<?=h($ogImageUrl)?>">
<meta name="twitter:title" content="<?=h($ogImageAlt)?>">
<meta name="twitter:description" content="Motorcycle road stories, hockey passion, pizza nerdism and facts remembered by an unreliable narrator.">
<link rel="alternate" type="application/rss+xml" title="MVLog RSS" href="/feed.php">
<link rel="icon" type="image/png" href="/assets/img/moto_travel.png">
<link rel="stylesheet" href="/style.css?v=20260531u">
<style>
.filter-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:0 0 .4rem}
.filter-input-wrap{position:relative;min-width:0}
.filter-input-wrap input{margin:0;padding-right:2.15rem}
.filter-clear-inside{position:absolute;right:.45rem;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;border-radius:50%;background:#C46A3A;color:#111315;text-decoration:none;font-size:1rem;line-height:1;font-weight:700;box-shadow:0 1px 4px #0007;transition:opacity .15s ease,transform .15s ease}
.filter-clear-inside:hover{background:#d97b48;color:#111315;transform:translateY(-50%) scale(1.05)}
.filter-clear-inside.is-empty{opacity:.38;pointer-events:none}
.filter-submit{display:inline-grid;place-items:center;width:2.35rem;height:2.35rem;padding:0;border-radius:.5rem;background:#C46A3A;color:#111315}
.filter-submit .icon{font-size:1.02rem;line-height:1;transform:translateY(.01em)}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.filter-summary{color:#A0A4AB;font-size:.9rem;margin:0 0 .8rem}
.video-title-line{display:flex;align-items:flex-start;gap:.75rem;width:100%}
.video-title-line .video-row-title{flex:1 1 auto;min-width:0}
.video-row-title{font-size:1.35rem;line-height:1.2;color:#F3F4F6}
.video-row-title a{color:inherit;text-decoration:none;cursor:pointer}
.video-row-title a:visited{color:inherit}
.video-row-title a:hover,.video-row-title a:focus,.video-row-title a:active{text-decoration:none}
.article-share-button{box-sizing:border-box;appearance:none;-webkit-appearance:none;display:grid;place-items:center;flex:0 0 auto;width:1.45rem;height:1.45rem;margin:.02rem 0 0 auto;padding:0;border:1px solid #C46A3A;border-radius:.35rem;background:#C46A3A;color:#fff;line-height:1;text-decoration:none}
.article-share-button svg{width:.78rem;height:.78rem;fill:currentColor}
.article-share-button:hover,.article-share-button:focus-visible{background:#d97b48;border-color:#d97b48;color:#fff}
.article-share-button.copied{background:#3BA7A0;border-color:#3BA7A0;color:#111315}
.video-row-head-mobile{display:none}
.video-row-head-mobile .video-row-title{margin:0 0 .15rem}
.video-row-head-mobile .meta{margin:0 0 .25rem}
.video-row-head-mobile .description-teaser{margin:.1rem 0 .3rem}
.video-info .video-row-title{margin:0 0 .35rem}
.video-info .meta{margin:0 0 .45rem}
.description-teaser{margin:.15rem 0 .45rem;color:#EADFC9}
.description-quote{margin:.15rem 0 .45rem;font-style:italic;font-size:.82rem;color:#D6D9DE}
.description-contact-link{color:#3BA7A0;text-decoration:underline;text-underline-offset:.12em}
.description-contact-link:visited{color:#3BA7A0}
.description-contact-link:hover,.description-contact-link:focus-visible{color:#4fb9b2}
.external-link-icon{font-size:.78em;margin-left:.15em;vertical-align:.12em}
#videos .video-wrapper{align-self:start}
#videos .video-info{align-self:start}
#videos .video-info .description{margin-top:0}
.meta a{color:inherit;text-decoration:none}
.meta a:hover{text-decoration:underline}
.meta-filter{display:inline-flex;align-items:center;gap:.25rem;padding:.12rem .5rem;border-radius:999px;background:rgba(255,255,255,.06);cursor:pointer;user-select:none;transition:background-color .15s ease,transform .15s ease}
.meta-filter:hover,.meta-filter:focus-visible{background:rgba(255,255,255,.12);transform:translateY(-1px);outline:none}
.meta-filter:focus-visible{box-shadow:0 0 0 2px rgba(202,162,109,.35)}
.site-header.admin-header{padding-top:.9rem;padding-bottom:1.0rem}
@media (max-width:900px){
.video-row-head-mobile{display:block;grid-column:1 / -1;margin:0 0 .05rem}
.video-row-head-mobile .description-teaser{margin:.25rem 0 .3rem}
#videos .video-wrapper{grid-column:1 / -1}
#videos .video-info{grid-column:1 / -1}
.video-info .video-title-line.video-title-line-desktop,
.video-info .video-row-title.video-row-title-desktop,
.video-info .meta.meta-desktop,
.video-info .description-teaser.description-teaser-desktop{display:none}
#videos .video-info .description{margin-top:.8rem}
}
</style>
<?php if ($articleId !== ''): ?><style>main{padding-bottom:0}</style><?php endif; ?>
</head>
<body>
<?php include __DIR__ . '/_header.php'; ?>
<main>
<section id="videos">
<?php if ($articleId === ''): ?>
<form method="get" class="filter-form">
<div class="filter-input-wrap">
<input name="q" value="<?=h($rawKeyword)?>" placeholder="Search title/teaser/quote_da/description or use date=... / location=..." aria-label="Search articles">
<a class="filter-clear-inside <?=$rawKeyword === '' ? 'is-empty' : ''?>" href="/" aria-label="Clear filter" title="Clear filter">×</a>
</div>
<button type="submit" class="filter-submit" aria-label="Filter">
<span class="icon" aria-hidden="true">🔍</span>
<span class="sr-only">Filter</span>
</button>
</form>
<?php endif; ?>
<?php if(!$slice): ?><p>No videos found for current filter.</p><?php endif; ?>
<div class="video-list">
<?php foreach($slice as $v):
$name = basename($v);
$base = pathinfo($name, PATHINFO_FILENAME);
$articleIdForVideo = video_article_id($config, $name);
$permalinkEnabled = $articleIdForVideo !== '' && article_permalink_enabled($config, $articleIdForVideo);
$permalinkUrl = $permalinkEnabled ? article_permalink_url($config, $articleIdForVideo, $videoCache, false) : '';
$shareUrl = $permalinkEnabled ? article_permalink_url($config, $articleIdForVideo, $videoCache, true) : '';
$url = '/' . ltrim((string)$config['public_videos'], '/') . '/'.rawurlencode($name);
$m = $videoCache[$name]['metadata'] ?? video_metadata($v);
$posterUrl = video_thumb_public_url($config, $name);
$dateFilterUrl = query_url(['q' => 'date=' . (string)($m['date'] ?? '')]);
$locFilterUrl = query_url(['q' => 'location=' . (string)($m['location'] ?? '')]);
?>
<article class="video-row">
<div class="video-row-head video-row-head-mobile">
<div class="video-title-line"><h2 class="video-row-title"><?php if($permalinkUrl !== ''): ?><a href="<?=h($permalinkUrl)?>"><?=h($m['title'])?></a><?php else: ?><?=h($m['title'])?><?php endif; ?></h2><?php if($shareUrl !== ''): ?><button class="article-share-button" type="button" data-share-url="<?=h($shareUrl)?>" data-share-title="<?=h($m['title'])?>" aria-label="Share article" title="Share article"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 16.1c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11A2.99 2.99 0 1 0 15 5c0 .24.04.47.09.7L8.04 9.81A3 3 0 1 0 8.04 14.2l7.12 4.18c-.05.2-.07.41-.07.62a2.91 2.91 0 1 0 2.91-2.9Z"/></svg></button><?php endif; ?></div>
<p class="meta">
<?php if(!empty($m['date'])): ?><a class="meta-filter" href="<?=h($dateFilterUrl)?>" title="Filter by date"><?=h($m['date'])?></a><?php endif; ?>
<?php if(!empty($m['location'])): ?><a class="meta-filter" href="<?=h($locFilterUrl)?>" title="Filter by location"><?=h($m['location'])?></a><?php endif; ?>
</p>
<?php if(!empty($m['teaser'])): ?><p class="description-teaser description-teaser-mobile"><?=render_description_html($m['teaser'])?></p><?php endif; ?>
</div>
<div class="video-wrapper">
<video controls controlsList="nodownload" oncontextmenu="return false" preload="metadata" src="<?=h($url)?>"<?= $posterUrl !== '' ? ' poster="'.h($posterUrl).'"' : '' ?>></video>
<?php
$map = pathinfo($name, PATHINFO_FILENAME)."_map.png";
$hideMap = !empty($hiddenMapOutputs[$name]);
if(!$hideMap && is_file($config['videos_dir']."/".$map)):
?>
<a class="map-image-link" href="<?=h("/" . ltrim((string)$config['public_videos'], "/") . "/" . rawurlencode($map))?>"><img class="map-image" src="<?=h("/" . ltrim((string)$config['public_videos'], "/") . "/" . rawurlencode($map))?>" alt="Map"></a>
<?php endif; ?>
</div>
<div class="video-info">
<div class="video-title-line video-title-line-desktop"><h2 class="video-row-title video-row-title-desktop"><?php if($permalinkUrl !== ''): ?><a href="<?=h($permalinkUrl)?>"><?=h($m['title'])?></a><?php else: ?><?=h($m['title'])?><?php endif; ?></h2><?php if($shareUrl !== ''): ?><button class="article-share-button" type="button" data-share-url="<?=h($shareUrl)?>" data-share-title="<?=h($m['title'])?>" aria-label="Share article" title="Share article"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 16.1c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11A2.99 2.99 0 1 0 15 5c0 .24.04.47.09.7L8.04 9.81A3 3 0 1 0 8.04 14.2l7.12 4.18c-.05.2-.07.41-.07.62a2.91 2.91 0 1 0 2.91-2.9Z"/></svg></button><?php endif; ?></div>
<p class="meta meta-desktop">
<?php if(!empty($m['date'])): ?><a class="meta-filter" href="<?=h($dateFilterUrl)?>" title="Filter by date"><?=h($m['date'])?></a><?php endif; ?>
<?php if(!empty($m['location'])): ?><a class="meta-filter" href="<?=h($locFilterUrl)?>" title="Filter by location"><?=h($m['location'])?></a><?php endif; ?>
</p>
<?php if(!empty($m['teaser'])): ?><p class="description-teaser description-teaser-desktop"><?=render_description_html($m['teaser'])?></p><?php endif; ?>
<?php if(!empty($m['description'])): ?><p class="description"><?=render_description_html($m['description'])?></p><?php endif; ?>
<?php if(!empty($m['quote_da'])): ?><p class="description-quote">“<?=h(trim((string)$m['quote_da'], "\"“”"))?>”</p><?php endif; ?>
</div>
</article>
<?php endforeach; ?>
</div>
<?php if ($articleId === ''): ?>
<div class="pages">
<?php for($i=1;$i<=$pages;$i++):
$linkParams = $baseParams;
if ($i > 1) $linkParams['page'] = $i;
?>
<a class="<?=$i===$page?'active':''?>" href="<?=h(query_url($linkParams))?>"><?=$i?></a>
<?php endfor; ?>
</div>
<?php endif; ?>
</section>
</main>
<script>
(function(){
function absoluteUrl(url){ try { return new URL(url, window.location.href).href; } catch(e) { return url; } }
function markCopied(btn){
const oldTitle = btn.title;
btn.classList.add('copied');
btn.title = 'Link copied';
setTimeout(function(){ btn.classList.remove('copied'); btn.title = oldTitle || 'Share article'; }, 1300);
}
async function copyUrl(url){
if(navigator.clipboard && window.isSecureContext){ await navigator.clipboard.writeText(url); return true; }
window.prompt('Copy article link:', url);
return true;
}
document.addEventListener('click', async function(event){
const btn = event.target.closest('.article-share-button');
if(!btn) return;
event.preventDefault();
const url = absoluteUrl(btn.dataset.shareUrl || '');
const title = btn.dataset.shareTitle || document.title || 'MVLog';
if(!url) return;
try{
if(navigator.share){
await navigator.share({ title: title, url: url });
}else{
await copyUrl(url);
markCopied(btn);
}
}catch(e){
if(e && e.name === 'AbortError') return;
try{ await copyUrl(url); markCopied(btn); }catch(ignore){}
}
});
})();
</script>
<?php include __DIR__ . '/_footer.php'; ?>
</body>
</html>