584 lines
28 KiB
PHP
584 lines
28 KiB
PHP
<?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 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)) : 'index.php';
|
||
}
|
||
|
||
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 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 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 = array_values(array_filter(
|
||
glob($config['videos_dir'].'/*.{mp4,webm,mov,m4v}', GLOB_BRACE) ?: [],
|
||
fn($v)=>!preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', basename($v))
|
||
));
|
||
|
||
$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;
|
||
});
|
||
|
||
$articleId = trim((string)($_GET['id'] ?? ''));
|
||
if ($articleId !== '' && !preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $articleId)) {
|
||
render_404_page();
|
||
}
|
||
if ($articleId !== '') {
|
||
if (!article_permalink_enabled($config, $articleId)) {
|
||
render_404_page();
|
||
}
|
||
$videos = array_values(array_filter($allVideos, function($v) use ($articleId, $config) {
|
||
return video_article_id($config, basename($v)) === $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 = 'MVLog: journal of an unreliable narrator.';
|
||
$ogType = ($articleId !== '') ? 'article' : 'website';
|
||
$ogUrl = ($articleId !== '') ? ('https://bubulescu.org/?id=' . rawurlencode($articleId)) : '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 = 'MVLog: ' . $firstTitle;
|
||
$pageTitle = 'MVLog: ' . $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">
|
||
<title><?=h($pageTitle)?></title>
|
||
<?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}
|
||
#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="index.php" 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 ? query_url(['id' => $articleIdForVideo]) : '';
|
||
$shareUrl = $permalinkEnabled ? ('https://bubulescu.org/?id=' . rawurlencode($articleIdForVideo)) : '';
|
||
$url = $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"><?=h((string)$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($config['public_videos']."/".rawurlencode($map))?>"><img class="map-image" src="<?=h($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"><?=h((string)$m['teaser'])?></p><?php endif; ?>
|
||
<?php if(!empty($m['description'])): ?><p class="description"><?=nl2br(h($m['description']), false)?></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>
|