Add index filters and title-targeted push links; polish clear icon control

This commit is contained in:
hbrain 2026-05-31 14:38:51 +02:00
parent de1c753a42
commit d407c40d75
2 changed files with 213 additions and 36 deletions

215
index.php
View file

@ -3,8 +3,16 @@ date_default_timezone_set('Europe/Copenhagen');
$config = require __DIR__ . "/config.php"; $config = require __DIR__ . "/config.php";
foreach (["videos_dir", "thumbs_dir", "uploads_dir"] as $d) if (!is_dir($config[$d])) mkdir($config[$d], 0775, true); 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); if (!is_dir(__DIR__ . "/cache")) mkdir(__DIR__ . "/cache", 0775, true);
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, "UTF-8"); } function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, "UTF-8"); }
function nice_title($s){ return trim(ucwords(str_replace(['_','-'], ' ', $s))); } 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 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){ function hidden_video_outputs($config){
$hidden = []; $hidden = [];
foreach (glob($config['uploads_dir'].'/*', GLOB_ONLYDIR) ?: [] as $dir) { foreach (glob($config['uploads_dir'].'/*', GLOB_ONLYDIR) ?: [] as $dir) {
@ -14,34 +22,47 @@ function hidden_video_outputs($config){
} }
return $hidden; return $hidden;
} }
function video_metadata($path){ function video_metadata($path){
$base = pathinfo($path, PATHINFO_FILENAME); $base = pathinfo($path, PATHINFO_FILENAME);
$meta = ['title' => nice_title($base), 'date' => date('Y-m-d', filemtime($path)), 'sort_date' => date('Y-m-d', filemtime($path)), 'location' => '', 'description' => '']; $meta = [
'title' => nice_title($base),
'date' => date('Y-m-d', filemtime($path)),
'sort_date' => date('Y-m-d', filemtime($path)),
'location' => '',
'description' => ''
];
if (preg_match('/^(\d{4})(\d{2})(\d{2})[_-](.+)$/', $base, $m)) { if (preg_match('/^(\d{4})(\d{2})(\d{2})[_-](.+)$/', $base, $m)) {
$meta['date'] = "$m[1]-$m[2]-$m[3]"; $meta['date'] = "$m[1]-$m[2]-$m[3]";
$meta['sort_date'] = "$m[1]-$m[2]-$m[3]"; $meta['sort_date'] = "$m[1]-$m[2]-$m[3]";
$meta['title'] = nice_title($m[4]); $meta['title'] = nice_title($m[4]);
} }
foreach ([dirname($path).'/'.$base.'.txt', dirname($path).'/'.$base.'.data.txt'] as $sidecar) { foreach ([dirname($path).'/'.$base.'.txt', dirname($path).'/'.$base.'.data.txt'] as $sidecar) {
if (!is_file($sidecar)) continue; if (!is_file($sidecar)) continue;
foreach (file($sidecar, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { foreach (file($sidecar, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (!str_contains($line, ':')) continue; if (!str_contains($line, ':')) continue;
[$k, $v] = array_map('trim', explode(':', $line, 2)); [$k, $v] = array_map('trim', explode(':', $line, 2));
$k = strtolower($k); $k = strtolower($k);
if (in_array($k, ['title','date','location','place','description'], true)) $meta[$k === 'place' ? 'location' : $k] = $v; if (in_array($k, ['title','date','location','place','description'], true)) {
$meta[$k === 'place' ? 'location' : $k] = $v;
} }
} }
}
$ffprobe = trim((string)shell_exec('command -v ffprobe 2>/dev/null')); $ffprobe = trim((string)shell_exec('command -v ffprobe 2>/dev/null'));
if ($ffprobe !== '') { if ($ffprobe !== '') {
$json = shell_exec(escapeshellarg($ffprobe).' -v quiet -print_format json -show_entries format_tags '.escapeshellarg($path).' 2>/dev/null'); $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); $data = json_decode((string)$json, true);
$tags = $data['format']['tags'] ?? []; $tags = $data['format']['tags'] ?? [];
$lower = []; $lower = [];
foreach ($tags as $k => $v) $lower[strtolower($k)] = $v; foreach ($tags as $k => $v) $lower[strtolower((string)$k)] = $v;
if (!empty($lower['title'])) $meta['title'] = $lower['title']; if (!empty($lower['title'])) $meta['title'] = $lower['title'];
if (!empty($lower['date'])) $meta['date'] = $lower['date']; if (!empty($lower['date'])) $meta['date'] = $lower['date'];
if (!empty($lower['creation_time'])) { if (!empty($lower['creation_time'])) {
$meta['sort_date'] = substr($lower['creation_time'], 0, 10); $meta['sort_date'] = substr((string)$lower['creation_time'], 0, 10);
if (empty($lower['date'])) $meta['date'] = $meta['sort_date']; 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) { foreach (['location','place','location-eng','com.apple.quicktime.location.name','com.apple.quicktime.location.iso6709'] as $k) {
@ -51,8 +72,10 @@ function video_metadata($path){
if (!empty($lower[$k])) { $meta['description'] = $lower[$k]; break; } if (!empty($lower[$k])) { $meta['description'] = $lower[$k]; break; }
} }
} }
return $meta; return $meta;
} }
function load_video_cache($videos){ function load_video_cache($videos){
$cacheFile = __DIR__ . '/cache/videos.json'; $cacheFile = __DIR__ . '/cache/videos.json';
$cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : []; $cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : [];
@ -60,6 +83,7 @@ function load_video_cache($videos){
$items = $cache['items'] ?? []; $items = $cache['items'] ?? [];
$newItems = []; $newItems = [];
$changed = false; $changed = false;
foreach ($videos as $path) { foreach ($videos as $path) {
$key = basename($path); $key = basename($path);
$mtime = filemtime($path); $mtime = filemtime($path);
@ -71,41 +95,174 @@ function load_video_cache($videos){
} }
$newItems[$key] = $cached; $newItems[$key] = $cached;
} }
if (array_diff(array_keys($items), array_keys($newItems))) $changed = true; if (array_diff(array_keys($items), array_keys($newItems))) $changed = true;
if ($changed) { if ($changed) {
file_put_contents($cacheFile, json_encode(['version' => 2, 'generated_at' => date('c'), 'items' => $newItems], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX); file_put_contents($cacheFile, json_encode([
'version' => 2,
'generated_at' => date('c'),
'items' => $newItems
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
} }
return $newItems; return $newItems;
} }
$hiddenOutputs = hidden_video_outputs($config); $hiddenOutputs = hidden_video_outputs($config);
$videos = array_values(array_filter(glob($config['videos_dir'].'/*.{mp4,webm,mov,m4v}', GLOB_BRACE) ?: [], fn($v)=>empty($hiddenOutputs[basename($v)]) && !preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', basename($v)))); $videos = array_values(array_filter(
glob($config['videos_dir'].'/*.{mp4,webm,mov,m4v}', GLOB_BRACE) ?: [],
fn($v)=>empty($hiddenOutputs[basename($v)]) && !preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', basename($v))
));
$videoCache = load_video_cache($videos); $videoCache = load_video_cache($videos);
usort($videos, function($a, $b) use ($videoCache) { usort($videos, function($a, $b) use ($videoCache) {
$am = $videoCache[basename($a)] ?? []; $am = $videoCache[basename($a)] ?? [];
$bm = $videoCache[basename($b)] ?? []; $bm = $videoCache[basename($b)] ?? [];
$ad = strtotime($am['metadata']['sort_date'] ?? $am['metadata']['date'] ?? '') ?: filemtime($a); $ad = strtotime((string)($am['metadata']['sort_date'] ?? $am['metadata']['date'] ?? '')) ?: filemtime($a);
$bd = strtotime($bm['metadata']['sort_date'] ?? $bm['metadata']['date'] ?? '') ?: filemtime($b); $bd = strtotime((string)($bm['metadata']['sort_date'] ?? $bm['metadata']['date'] ?? '')) ?: filemtime($b);
return $bd <=> $ad; return $bd <=> $ad;
}); });
$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);
$filters = [
'f_title' => trim((string)($_GET['f_title'] ?? '')),
'f_date' => trim((string)($_GET['f_date'] ?? '')),
'f_location' => trim((string)($_GET['f_location'] ?? '')),
];
$titleExact = !empty($_GET['f_exact']) && (string)$_GET['f_exact'] === '1';
// Backward compatibility with old push URL format (?job=...)
$legacyJob = trim((string)($_GET['job'] ?? ''));
if ($filters['f_title'] === '' && $legacyJob !== '') {
$legacy = preg_replace('/^\d{8}[_-]/', '', $legacyJob);
$filters['f_title'] = nice_title($legacy ?: $legacyJob);
}
$videos = array_values(array_filter($videos, function($v) use ($videoCache, $filters) {
$name = basename($v);
$m = $videoCache[$name]['metadata'] ?? video_metadata($v);
$title = (string)($m['title'] ?? '');
$date = (string)($m['date'] ?? '');
$sortDate = (string)($m['sort_date'] ?? '');
$location = (string)($m['location'] ?? '');
if ($filters['f_title'] !== '') {
if ($titleExact) {
if (lower_text(trim($title)) !== lower_text(trim($filters['f_title']))) return false;
} else {
if (stripos($title, $filters['f_title']) === false) return false;
}
}
if ($filters['f_date'] !== '' && stripos($date, $filters['f_date']) === false && stripos($sortDate, $filters['f_date']) === false) return false;
if ($filters['f_location'] !== '' && stripos($location, $filters['f_location']) === false) return false;
return true;
}));
$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);
$activeFilterCount = count(keep_non_empty($filters));
$baseParams = keep_non_empty($filters);
if ($titleExact && ($filters['f_title'] ?? '') !== '') $baseParams['f_exact'] = '1';
?> ?>
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>MVLog</title><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"></head><body> <!doctype html>
<header class="site-header admin-header"><div class="brand-wrap"><a class="header-logo" href="index.php" aria-label="MVLog home"><img src="assets/img/moto_travel.png" alt=""></a><a class="brand" href="index.php"><h1>MVLog</h1><p>Bubulescu.Org</p></a></div><button id="push-toggle" class="push-toggle" type="button" hidden aria-label="Notifications" title="Notifications"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22a2.7 2.7 0 0 0 2.6-2h-5.2A2.7 2.7 0 0 0 12 22Zm7-6V11a7 7 0 0 0-5-6.7V3a2 2 0 0 0-4 0v1.3A7 7 0 0 0 5 11v5l-2 2v1h18v-1l-2-2Z"/></svg></button><a class="rss-link" href="feed.php" aria-label="RSS feed" title="RSS feed"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.2 17.8a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM2.2 8.7v3.1a10 10 0 0 1 10 10h3.1A13.1 13.1 0 0 0 2.2 8.7Zm0-5.5v3.1a15.5 15.5 0 0 1 15.5 15.5h3.1A18.6 18.6 0 0 0 2.2 3.2Z"/></svg></a></header><main> <html>
<section id="videos"><?php if(!$slice): ?><p>No videos yet.</p><?php endif; ?><div class="video-list"> <head>
<?php foreach($slice as $v): $name=basename($v); $url=$config['public_videos'].'/'.rawurlencode($name); $m=$videoCache[$name]['metadata'] ?? video_metadata($v); ?> <meta charset="utf-8">
<article class="video-row"><time class="created-at" datetime="<?=h(date('c', filemtime($v)))?>"><?=h(date('d.m.Y H:i', filemtime($v)))?></time> <meta name="viewport" content="width=device-width,initial-scale=1">
<div class="video-wrapper"> <title>MVLog</title>
<video controls controlsList="nodownload" oncontextmenu="return false" preload="metadata" src="<?=h($url)?>"></video> <link rel="alternate" type="application/rss+xml" title="MVLog RSS" href="feed.php">
<?php <link rel="icon" type="image/png" href="assets/img/moto_travel.png">
$map=pathinfo($name, PATHINFO_FILENAME).".png"; <link rel="stylesheet" href="style.css">
if(is_file($config["videos_dir"]."/".$map)): <style>
?> .filter-form{display:grid;grid-template-columns:1.4fr 1fr 1fr auto auto;gap:.55rem;align-items:end;margin:0 0 1rem}
<img class="map-image" src="<?=h($config["public_videos"]."/".rawurlencode($map))?>" alt="Map"> .filter-form label{margin:0;font-size:.8rem;color:#A0A4AB;font-weight:600}
<?php endif; ?> .filter-form input{margin-top:.2rem}
</div> .filter-clear-x{display:inline-flex;align-items:center;justify-content:center;width:2.1rem;height:2.1rem;border-radius:50%;background:#C46A3A;border:1px solid #C46A3A;color:#111315;text-decoration:none;font-size:0;position:relative;box-shadow:0 1px 4px #0008}
<div class="video-info"><h2><?=h($m['title'])?></h2><p class="meta"><span><?=h($m['date'])?></span><?php if($m['location']): ?><span><?=h($m['location'])?></span><?php endif; ?></p><?php if($m['description']): ?><p class="description"><?=nl2br(h($m['description']), false)?></p><?php endif; ?></div></article> .filter-clear-x::before{content:'✕';font-size:1rem;line-height:1;font-weight:700;transform:translateY(-.02em)}
<?php endforeach; ?></div><div class="pages"><?php for($i=1;$i<=$pages;$i++): ?><a class="<?=$i===$page?'active':''?>" href="?page=<?=$i?>"><?=$i?></a><?php endfor; ?></div></section> .filter-clear-x:hover{background:#d97b48;border-color:#d97b48;color:#111315}
</main><footer>Bubulescu.Org</footer><script> .filter-clear-x:focus-visible{outline:2px solid #3BA7A0;outline-offset:2px}
.filter-summary{color:#A0A4AB;font-size:.9rem;margin:0 0 .8rem}
.meta a{color:inherit;text-decoration:none}
.meta a:hover{text-decoration:underline}
@media (max-width:900px){.filter-form{grid-template-columns:1fr}}
</style>
</head>
<body>
<header class="site-header admin-header"><div class="brand-wrap"><a class="header-logo" href="index.php" aria-label="MVLog home"><img src="assets/img/moto_travel.png" alt=""></a><a class="brand" href="index.php"><h1>MVLog</h1><p>Bubulescu.Org</p></a></div><button id="push-toggle" class="push-toggle" type="button" hidden aria-label="Notifications" title="Notifications"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22a2.7 2.7 0 0 0 2.6-2h-5.2A2.7 2.7 0 0 0 12 22Zm7-6V11a7 7 0 0 0-5-6.7V3a2 2 0 0 0-4 0v1.3A7 7 0 0 0 5 11v5l-2 2v1h18v-1l-2-2Z"/></svg></button><a class="rss-link" href="feed.php" aria-label="RSS feed" title="RSS feed"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.2 17.8a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM2.2 8.7v3.1a10 10 0 0 1 10 10h3.1A13.1 13.1 0 0 0 2.2 8.7Zm0-5.5v3.1a15.5 15.5 0 0 1 15.5 15.5h3.1A18.6 18.6 0 0 0 2.2 3.2Z"/></svg></a></header>
<main>
<section id="videos">
<form method="get" class="filter-form">
<label>Title
<input name="f_title" value="<?=h($filters['f_title'])?>" placeholder="Filter by title">
</label>
<label>Date
<input name="f_date" value="<?=h($filters['f_date'])?>" placeholder="YYYY-MM-DD or month/year">
</label>
<label>Location
<input name="f_location" value="<?=h($filters['f_location'])?>" placeholder="Filter by location">
</label>
<a class="filter-clear-x" href="index.php" title="Clear filters" aria-label="Clear filters">&times;</a>
<button type="submit">Filter</button>
</form>
<p class="filter-summary">
Showing <?=h((string)$total)?> article(s)
<?php if($activeFilterCount > 0): ?> with <?=h((string)$activeFilterCount)?> active filter(s)<?php endif; ?>
</p>
<?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);
$url = $config['public_videos'].'/'.rawurlencode($name);
$m = $videoCache[$name]['metadata'] ?? video_metadata($v);
$dateFilterUrl = query_url(array_merge($baseParams, ['f_date' => (string)($m['date'] ?? '')]));
$locFilterUrl = query_url(array_merge($baseParams, ['f_location' => (string)($m['location'] ?? '')]));
?>
<article class="video-row">
<time class="created-at" datetime="<?=h(date('c', filemtime($v)))?>"><?=h(date('d.m.Y H:i', filemtime($v)))?></time>
<div class="video-wrapper">
<video controls controlsList="nodownload" oncontextmenu="return false" preload="metadata" src="<?=h($url)?>"></video>
<?php
$map = pathinfo($name, PATHINFO_FILENAME).".png";
if(is_file($config['videos_dir']."/".$map)):
?>
<img class="map-image" src="<?=h($config['public_videos']."/".rawurlencode($map))?>" alt="Map">
<?php endif; ?>
</div>
<div class="video-info">
<h2><?=h($m['title'])?></h2>
<p class="meta">
<span><a href="<?=h($dateFilterUrl)?>"><?=h($m['date'])?></a></span>
<?php if(!empty($m['location'])): ?><span><a href="<?=h($locFilterUrl)?>"><?=h($m['location'])?></a></span><?php endif; ?>
</p>
<?php if(!empty($m['description'])): ?><p class="description"><?=nl2br(h($m['description']), false)?></p><?php endif; ?>
</div>
</article>
<?php endforeach; ?>
</div>
<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>
</section>
</main>
<footer>Bubulescu.Org</footer>
<script>
(function(){ (function(){
const btn=document.getElementById('push-toggle'); const btn=document.getElementById('push-toggle');
if(!btn || !('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return; if(!btn || !('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return;
@ -133,4 +290,6 @@ if(is_file($config["videos_dir"]."/".$map)):
}); });
}); });
})(); })();
</script></body></html> </script>
</body>
</html>

View file

@ -68,6 +68,11 @@ function mvlog_write_temp_push_json(array $cfg): ?string
return $tmp; return $tmp;
} }
function mvlog_nice_title(string $s): string
{
return trim(ucwords(str_replace(['_', '-'], ' ', $s)));
}
function try_send_show_notification($job, $jobdir, $mvlog_root = null, $logfile = null) function try_send_show_notification($job, $jobdir, $mvlog_root = null, $logfile = null)
{ {
if ($mvlog_root === null) $mvlog_root = dirname(__DIR__); if ($mvlog_root === null) $mvlog_root = dirname(__DIR__);
@ -122,25 +127,38 @@ function try_send_show_notification($job, $jobdir, $mvlog_root = null, $logfile
return false; return false;
} }
// Determine a title/url for the notification. Prefer existing output filename if present. // Determine article title (used both in notification text and filtered URL).
$title = preg_replace('/_+/', ' ', $job); $articleTitle = mvlog_nice_title((string)$job);
$title = ucwords($title); if (preg_match('/^\d{8}[_-](.+)$/', (string)$job, $m)) {
$articleTitle = mvlog_nice_title((string)$m[1]);
}
$state_file = $jobdir . '/.movmaker-state.json'; $state_file = $jobdir . '/.movmaker-state.json';
if (is_file($state_file)) { if (is_file($state_file)) {
$state = json_decode(@file_get_contents($state_file), true) ?: []; $state = json_decode(@file_get_contents($state_file), true) ?: [];
if (!empty($state['output']) && is_string($state['output'])) { if (!empty($state['output']) && is_string($state['output'])) {
$out = pathinfo($state['output'], PATHINFO_FILENAME); $out = pathinfo($state['output'], PATHINFO_FILENAME);
if ($out) { if ($out) {
$title = preg_replace('/_+/', ' ', $out); if (preg_match('/^\d{8}[_-](.+)$/', (string)$out, $m)) {
$title = ucwords($title); $articleTitle = mvlog_nice_title((string)$m[1]);
} else {
$articleTitle = mvlog_nice_title((string)$out);
}
} }
} }
} }
if ($articleTitle === '') $articleTitle = mvlog_nice_title((string)$job);
$filterUrl = 'https://bubulescu.org/mvlog/?' . http_build_query([
'f_title' => $articleTitle,
'f_exact' => '1',
]);
$payload = json_encode([ $payload = json_encode([
'title' => 'Show enabled', 'title' => $articleTitle,
'body' => $title, 'body' => 'New MVLog article is now visible',
'url' => "https://bubulescu.org/mvlog/?job={$job}", 'url' => $filterUrl,
'tag' => "mvlog-show-{$job}", 'tag' => "mvlog-show-{$job}",
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);