Files
Ty Clifford 6a1c545f35 - Descriptions now render Markdown or sanitized HTML via [includes/db.php](/Users/tyemeclifford/Documents/GH/mediaplayer/includes/db.php).
Public player descriptions render formatted HTML on the homepage.
Catalogue cards, embed meta tags, and social metadata use safe 
plain-text excerpts.
Add/edit admin pages now use a rich description editor that preserves 
pasted HTML styling, with a textarea fallback for no-JS.
External/YouTube-imported descriptions are sanitized before storage.
README now documents Markdown/HTML description support.
2026-07-01 15:41:01 -04:00

552 lines
24 KiB
PHP
Raw Permalink 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
/**
* layout.php — shared HTML head, header, and footer partials
*/
function render_social_meta(string $title, array $meta = []): void {
$site_name = trim((string)($meta['site_name'] ?? setting('site_title', 'Ty Clifford')));
$description = trim((string)($meta['description'] ?? setting('site_sub', 'tyclifford.com / media')));
$type = trim((string)($meta['type'] ?? 'website'));
$url = trim((string)($meta['url'] ?? public_absolute_url($_SERVER['REQUEST_URI'] ?? public_home_url())));
$image = trim((string)($meta['image'] ?? ''));
$image_alt = trim((string)($meta['image_alt'] ?? $title));
$image_width = (int)($meta['image_width'] ?? 0);
$image_height = (int)($meta['image_height'] ?? 0);
$twitter_card = $image !== '' ? 'summary_large_image' : 'summary';
$url = public_absolute_url($url);
if ($image !== '') $image = public_absolute_url($image);
?>
<meta name="description" content="<?= h($description) ?>">
<link rel="canonical" href="<?= h($url) ?>">
<meta property="og:type" content="<?= h($type) ?>">
<meta property="og:site_name" content="<?= h($site_name) ?>">
<meta property="og:title" content="<?= h($title) ?>">
<meta property="og:description" content="<?= h($description) ?>">
<meta property="og:url" content="<?= h($url) ?>">
<?php if ($image !== ''): ?>
<meta property="og:image" content="<?= h($image) ?>">
<meta property="og:image:secure_url" content="<?= h($image) ?>">
<meta property="og:image:alt" content="<?= h($image_alt) ?>">
<?php if ($image_width > 0 && $image_height > 0): ?>
<meta property="og:image:width" content="<?= $image_width ?>">
<meta property="og:image:height" content="<?= $image_height ?>">
<?php endif; ?>
<?php endif; ?>
<meta name="twitter:card" content="<?= h($twitter_card) ?>">
<meta name="twitter:title" content="<?= h($title) ?>">
<meta name="twitter:description" content="<?= h($description) ?>">
<?php if ($image !== ''): ?>
<meta name="twitter:image" content="<?= h($image) ?>">
<meta name="twitter:image:alt" content="<?= h($image_alt) ?>">
<?php endif; ?>
<?php }
function render_head(string $title = 'Media — TyClifford.com', string $extra_css = '', array $meta = []): void { ?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= h($title) ?></title>
<?php render_social_meta($title, $meta); ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<!-- Video.js -->
<link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet" />
<script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script>
<style>
/* ── Reset & design tokens (mirrors gate.php exactly) ──────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #09090f;
--surface: #0f0f1a;
--surface-2: #141425;
--border: #1e1e38;
--border-2: #2a2a48;
--text: #e8e8f8;
--text-2: #9898c0;
--text-3: #58587a;
--green: #00e87a;
--blue: #00c8ff;
--purple: #b060ff;
--red: #ff3355;
--orange: #ff8800;
--yellow: #ffd040;
--pat: #ff424d;
--pat-lite: rgba(255,66,77,.10);
--pat-border:rgba(255,66,77,.32);
--pat-glow: rgba(255,66,77,.18);
--r: 6px;
--gap: 1.25rem;
--page-pad: 1rem;
}
@media (min-width:600px) { :root { --page-pad: 1.5rem; } }
@media (min-width:1000px) { :root { --page-pad: 2rem; } }
html { font-size:16px; -webkit-text-size-adjust:100%; scroll-behavior:smooth; }
body {
min-height: 100dvh;
background: var(--bg);
background-image:
radial-gradient(ellipse 70% 50% at 20% 10%, rgba(176,96,255,.05) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 80% 80%, rgba(0,200,255,.04) 0%, transparent 60%),
radial-gradient(ellipse 50% 60% at 50% 50%, rgba(255,66,77,.03) 0%, transparent 70%);
font-family: 'Inter', system-ui, sans-serif;
color: var(--text);
display: flex;
flex-direction: column;
align-items: center;
}
body::after {
content:''; position:fixed; inset:0; pointer-events:none; z-index:999;
background: repeating-linear-gradient(0deg,transparent 0px,transparent 3px,rgba(0,0,0,.06) 3px,rgba(0,0,0,.06) 4px);
}
@keyframes rise { from{opacity:0;transform:translateY(14px) scale(.98)} to{opacity:1;transform:translateY(0) scale(1)} }
@keyframes shimmer{ from{background-position:0% 50%} to{background-position:200% 50%} }
@keyframes pulse-border { 0%,100%{box-shadow:0 0 0 0 var(--pat-glow)} 50%{box-shadow:0 0 0 6px transparent} }
@keyframes live-pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
/* ── Page wrapper ── */
.page { width:100%; max-width:1100px; padding:var(--page-pad); display:flex; flex-direction:column; gap:1.5rem; animation:rise .45s cubic-bezier(.22,.68,0,1.2) both; }
/* ── Top bar ── */
.topbar { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
.topbar-brand { display:flex; align-items:center; gap:.75rem; }
.brand-name { font-size:1rem; font-weight:700; letter-spacing:-.01em; color:var(--text); text-decoration:none; }
.brand-sub { font-family:'Share Tech Mono',monospace; font-size:.62rem; letter-spacing:.18em; text-transform:uppercase; color:var(--text-3); }
.topbar-social { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
.social-pill {
display:inline-flex; align-items:center; gap:.4rem;
padding:.35rem .75rem;
background:rgba(255,255,255,.03);
border:1px solid var(--border-2);
border-radius:99px;
font-family:'Share Tech Mono',monospace; font-size:.65rem; letter-spacing:.08em;
color:var(--text-3); text-decoration:none;
transition:border-color .2s, color .2s, background .2s;
}
.social-pill:hover { color:var(--text-2); background:rgba(255,255,255,.06); }
.social-pill svg { flex-shrink:0; }
.social-pill.active { border-color:var(--purple); color:var(--purple); }
/* ── Cards ── */
.card {
background:var(--surface);
border:1px solid var(--border);
border-radius:calc(var(--r) + 2px);
padding:1.4rem 1.5rem;
display:flex; flex-direction:column; gap:.9rem;
position:relative; overflow:hidden;
}
.card::after {
content:''; position:absolute; bottom:-1px; right:-1px;
width:14px; height:14px;
border-bottom:2px solid var(--purple); border-right:2px solid var(--purple);
border-radius:0 0 calc(var(--r)+2px) 0; opacity:.5;
}
.card-title { font-size:.95rem; font-weight:700; letter-spacing:-.01em; color:var(--text); line-height:1.2; }
.card-body { font-size:.85rem; color:var(--text-2); line-height:1.7; }
.card-body strong { color:var(--text); font-weight:600; }
.card-body a { color:var(--pat); text-decoration:none; border-bottom:1px solid rgba(255,66,77,.25); transition:border-color .15s; }
.card-body a:hover { border-color:rgba(255,66,77,.6); }
/* ── Eyebrow ── */
.eyebrow { font-family:'Share Tech Mono',monospace; font-size:.62rem; letter-spacing:.2em; text-transform:uppercase; }
.eyebrow-green { color:var(--green); text-shadow:0 0 10px rgba(0,232,122,.5); }
.eyebrow-green::before { content:'> '; opacity:.5; }
.eyebrow-purple { color:var(--purple); }
.eyebrow-blue { color:var(--blue); }
/* ── Stream card / player card ── */
.stream-card {
background:var(--surface); border:1px solid var(--border);
border-radius:calc(var(--r)+2px); overflow:hidden; position:relative;
box-shadow:0 0 0 1px rgba(255,255,255,.03), 0 4px 50px rgba(0,0,0,.6);
}
.stream-card::before {
content:''; position:absolute; top:0; left:0; right:0; height:2px; z-index:2;
background:linear-gradient(90deg,var(--pat),var(--purple),var(--blue),var(--green),var(--blue),var(--purple),var(--pat));
background-size:300% 100%; animation:shimmer 5s linear infinite;
}
.stream-ratio { position:relative; width:100%; padding-top:56.25%; background:#000; }
.stream-ratio > * { position:absolute; inset:0; width:100%; height:100%; border:0; display:block; }
.stream-footer {
display:flex; align-items:center; justify-content:space-between; gap:.75rem;
padding:.75rem 1.1rem; border-top:1px solid var(--border); flex-wrap:wrap;
}
.stream-meta { font-family:'Share Tech Mono',monospace; font-size:.65rem; letter-spacing:.1em; color:var(--text-3); }
.stream-meta span { color:var(--text-2); }
/* ── Footer ── */
.page-footer {
padding:.5rem 0 1rem; display:flex; align-items:center; justify-content:center;
gap:1.5rem; flex-wrap:wrap; border-top:1px solid var(--border);
}
.footer-note { font-family:'Share Tech Mono',monospace; font-size:.63rem; color:var(--text-3); letter-spacing:.06em; text-align:center; }
.footer-note::before { content:'// '; opacity:.4; }
.footer-note a { color:var(--text-3); text-decoration:none; transition:color .15s; }
.footer-note a:hover { color:var(--text-2); }
/* ── Buttons ── */
.btn {
display:inline-flex; align-items:center; gap:.45rem;
padding:.6rem 1.1rem; border-radius:var(--r);
font-family:'Inter',sans-serif; font-size:.82rem; font-weight:600;
text-decoration:none; cursor:pointer; border:none;
transition:transform .15s, filter .15s, background .2s;
}
.btn:hover { transform:translateY(-1px); filter:brightness(1.08); }
.btn:active { transform:scale(.98); }
.btn-primary { background:var(--purple); color:#fff; box-shadow:0 4px 18px rgba(176,96,255,.25); }
.btn-secondary { background:rgba(255,255,255,.06); color:var(--text-2); border:1px solid var(--border-2); }
.btn-secondary:hover { color:var(--text); background:rgba(255,255,255,.1); }
.btn-danger { background:rgba(255,51,85,.15); color:var(--red); border:1px solid rgba(255,51,85,.3); }
.btn-danger:hover { background:rgba(255,51,85,.25); }
.btn-green { background:rgba(0,232,122,.12); color:var(--green); border:1px solid rgba(0,232,122,.3); }
.btn-green:hover { background:rgba(0,232,122,.2); }
.btn-sm { padding:.38rem .75rem; font-size:.75rem; }
/* ── Form controls ── */
.form-group { display:flex; flex-direction:column; gap:.4rem; }
.form-label { font-family:'Share Tech Mono',monospace; font-size:.65rem; letter-spacing:.12em; text-transform:uppercase; color:var(--text-3); }
.form-input, .form-select, .form-textarea {
background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--r);
color:var(--text); padding:.6rem .85rem; font-family:'Inter',sans-serif; font-size:.85rem;
transition:border-color .2s;
width:100%;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline:none; border-color:var(--purple);
box-shadow:0 0 0 2px rgba(176,96,255,.15);
}
.form-textarea { resize:vertical; min-height:80px; }
.form-select option { background:var(--surface-2); }
.description-editor {
min-height:130px; line-height:1.55; overflow:auto; white-space:pre-wrap;
}
.description-editor:empty::before {
content:attr(data-placeholder); color:var(--text-3); pointer-events:none;
}
.description-editor :where(p, ul, ol, blockquote, pre, h1, h2, h3, h4, h5, h6) { margin:.45rem 0; }
.description-editor :where(ul, ol) { padding-left:1.35rem; }
.description-editor a,
.media-description a {
color:var(--blue); text-decoration:none; border-bottom:1px solid rgba(0,200,255,.35);
}
.description-editor a:hover,
.media-description a:hover { border-color:rgba(0,200,255,.7); }
.media-description {
font-family:"Inter",sans-serif; color:var(--text-2);
letter-spacing:0; line-height:1.6; overflow-wrap:anywhere;
}
.media-description :where(p, ul, ol, blockquote, pre, h1, h2, h3, h4, h5, h6) { margin:.55rem 0; }
.media-description :where(p:first-child, ul:first-child, ol:first-child, blockquote:first-child, pre:first-child, h1:first-child, h2:first-child, h3:first-child, h4:first-child, h5:first-child, h6:first-child) { margin-top:0; }
.media-description :where(p:last-child, ul:last-child, ol:last-child, blockquote:last-child, pre:last-child, h1:last-child, h2:last-child, h3:last-child, h4:last-child, h5:last-child, h6:last-child) { margin-bottom:0; }
.media-description :where(ul, ol) { padding-left:1.35rem; }
.media-description :where(h1, h2, h3, h4, h5, h6) { color:var(--text); line-height:1.25; font-size:.95rem; }
.media-description blockquote {
padding-left:.85rem; border-left:2px solid var(--border-2); color:var(--text-2);
}
.media-description code {
font-family:"Share Tech Mono",monospace; font-size:.9em;
background:rgba(255,255,255,.06); border:1px solid var(--border-2);
border-radius:4px; padding:.08rem .28rem;
}
.media-description pre {
overflow:auto; background:rgba(0,0,0,.24); border:1px solid var(--border);
border-radius:var(--r); padding:.7rem;
}
.media-description pre code { background:transparent; border:0; padding:0; }
.media-description img { max-width:100%; height:auto; border-radius:var(--r); }
/* ── Notice/alert ── */
.notice {
padding:.75rem 1rem; border-radius:var(--r); font-size:.82rem;
border:1px solid; margin-bottom:1rem;
}
.notice-success { background:rgba(0,232,122,.08); border-color:rgba(0,232,122,.3); color:var(--green); }
.notice-error { background:rgba(255,51,85,.08); border-color:rgba(255,51,85,.3); color:var(--red); }
.notice-info { background:rgba(0,200,255,.08); border-color:rgba(0,200,255,.3); color:var(--blue); }
/* ── Pagination ── */
.pagination { display:flex; align-items:center; gap:.4rem; flex-wrap:wrap; }
.pagination a, .pagination span {
display:inline-flex; align-items:center; justify-content:center;
min-width:2rem; height:2rem; padding:0 .5rem;
border-radius:var(--r); font-family:'Share Tech Mono',monospace; font-size:.72rem;
text-decoration:none; transition:background .2s, color .2s, border-color .2s;
border:1px solid var(--border-2); color:var(--text-3);
}
.pagination a:hover { border-color:var(--purple); color:var(--purple); background:rgba(176,96,255,.08); }
.pagination .current { border-color:var(--purple); color:var(--purple); background:rgba(176,96,255,.15); }
.pagination .disabled { opacity:.3; pointer-events:none; }
<?= $extra_css ?>
</style>
</head>
<body>
<?php }
function render_topbar(string $active = ''): void {
$title = setting('site_title', 'Ty Clifford');
$sub = setting('site_sub', 'tyclifford.com / player');
?>
<header class="topbar">
<div class="topbar-brand">
<div>
<a href="<?= h(public_home_url()) ?>" class="brand-name"><?= h($title) ?></a>
<div class="brand-sub"><?= h($sub) ?></div>
</div>
</div>
<nav class="topbar-social" aria-label="Navigation">
<a class="social-pill <?= $active==='home' ? 'active' : '' ?>" href="<?= h(public_home_url()) ?>">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>
Home
</a>
<a class="social-pill <?= $active==='catalogue' ? 'active' : '' ?>" href="<?= h(public_catalogue_url()) ?>">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Videos
</a>
</nav>
</header>
<?php }
function render_footer(): void { ?>
<footer class="page-footer" role="contentinfo">
<p class="footer-note">TyClifford.com &nbsp;·&nbsp; player</p>
<p class="footer-note"><a href="https://git.tyclifford.com/">open source</a></p>
<p class="footer-note">hobbies are my hobby</p>
<p class="footer-note"><a href="mailto:ty@tyclifford.com">ty@tyclifford.com</a></p>
</footer>
<?php }
function render_description_editor(string $description): void {
$description = trim($description);
$is_html = media_description_contains_html($description);
?>
<textarea class="form-textarea description-fallback" id="description" name="description" rows="5"><?= h($description) ?></textarea>
<div class="description-editor form-textarea"
id="description-editor"
hidden
contenteditable="true"
role="textbox"
aria-labelledby="description-label"
aria-multiline="true"
data-rich="<?= $is_html ? '1' : '0' ?>"
data-placeholder="Description"><?php if ($is_html): ?><?= media_description_html($description) ?><?php else: ?><?= h($description) ?><?php endif; ?></div>
<?php }
function render_description_editor_script(): void { ?>
<script>
(function() {
var textarea = document.getElementById('description');
var editor = document.getElementById('description-editor');
if (!textarea || !editor) return;
textarea.hidden = true;
editor.hidden = false;
var rich = editor.dataset.rich === '1';
var allowedTags = {
A: true, B: true, BLOCKQUOTE: true, BR: true, CODE: true, DIV: true,
EM: true, H1: true, H2: true, H3: true, H4: true, H5: true, H6: true,
I: true, IMG: true, LI: true, OL: true, P: true, PRE: true, S: true,
SPAN: true, STRONG: true, U: true, UL: true
};
var dropTags = {
AUDIO: true, BASE: true, BUTTON: true, CANVAS: true, EMBED: true,
FORM: true, IFRAME: true, INPUT: true, LINK: true, MATH: true,
META: true, OBJECT: true, PICTURE: true, SCRIPT: true, SELECT: true,
SOURCE: true, STYLE: true, SVG: true, TEXTAREA: true, TRACK: true,
VIDEO: true
};
var styleProps = [
'background-color', 'color', 'font-size', 'font-style', 'font-weight',
'letter-spacing', 'line-height', 'text-align', 'text-decoration',
'text-transform'
];
function isSafeUrl(value, forSrc) {
value = (value || '').trim();
if (!value) return '';
if (value.indexOf('//') === 0) return 'https:' + value;
if (value.charAt(0) === '#') return forSrc ? '' : value;
if (value.charAt(0) === '/') return value;
try {
var parsed = new URL(value, window.location.origin);
var protocol = parsed.protocol.replace(':', '').toLowerCase();
if (forSrc) return (protocol === 'http' || protocol === 'https') ? value : '';
return ['http', 'https', 'mailto', 'tel'].indexOf(protocol) !== -1 ? value : '';
} catch (e) {
return /^[^<>"']+$/.test(value) ? value : '';
}
}
function cleanStyle(value) {
var probe = document.createElement('span');
probe.setAttribute('style', value || '');
var kept = [];
styleProps.forEach(function(prop) {
var v = probe.style.getPropertyValue(prop);
if (!v || /url\s*\(|expression\s*\(|javascript:|data:|@|[<>\\]/i.test(v)) return;
kept.push(prop + ': ' + v);
});
return kept.join('; ');
}
function sanitizeElement(el) {
Array.prototype.slice.call(el.childNodes).forEach(function(child) {
if (child.nodeType === Node.TEXT_NODE) return;
if (child.nodeType !== Node.ELEMENT_NODE) {
child.remove();
return;
}
if (dropTags[child.tagName]) {
child.remove();
return;
}
sanitizeElement(child);
if (!allowedTags[child.tagName]) {
while (child.firstChild) child.parentNode.insertBefore(child.firstChild, child);
child.remove();
return;
}
Array.prototype.slice.call(child.attributes).forEach(function(attr) {
var name = attr.name.toLowerCase();
var value = attr.value;
var keep = name === 'title' || name === 'style';
if (child.tagName === 'A') keep = keep || name === 'href' || name === 'target' || name === 'rel';
if (child.tagName === 'IMG') keep = keep || name === 'src' || name === 'alt' || name === 'width' || name === 'height';
if (child.tagName === 'OL') keep = keep || name === 'start';
if (name.indexOf('on') === 0 || !keep) {
child.removeAttribute(attr.name);
return;
}
if (name === 'style') {
var style = cleanStyle(value);
if (style) child.setAttribute('style', style);
else child.removeAttribute(attr.name);
}
if (name === 'href' || name === 'src') {
var url = isSafeUrl(value, name === 'src');
if (url) child.setAttribute(name, url);
else child.removeAttribute(attr.name);
}
});
if (child.tagName === 'A') {
if (child.getAttribute('href')) {
child.setAttribute('target', '_blank');
child.setAttribute('rel', 'noopener noreferrer');
} else {
child.removeAttribute('target');
child.removeAttribute('rel');
}
}
if (child.tagName === 'IMG' && child.getAttribute('src')) {
child.setAttribute('loading', 'lazy');
child.setAttribute('decoding', 'async');
}
if (child.tagName === 'IMG' && !child.getAttribute('src')) child.remove();
});
}
function sanitizeHtml(html) {
var template = document.createElement('template');
template.innerHTML = html || '';
sanitizeElement(template.content);
return template.innerHTML.trim();
}
function plainText() {
return editor.innerText.replace(/\u00a0/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
}
function syncDescription() {
textarea.value = rich ? sanitizeHtml(editor.innerHTML) : plainText();
}
function insertHtml(html) {
var selection = window.getSelection();
if (!selection || !selection.rangeCount) {
editor.insertAdjacentHTML('beforeend', html);
return;
}
var range = selection.getRangeAt(0);
range.deleteContents();
var fragment = range.createContextualFragment(html);
var last = fragment.lastChild;
range.insertNode(fragment);
if (last) {
range.setStartAfter(last);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
}
window.setDescriptionEditorValue = function(value) {
value = value || '';
if (/<\/?[a-z][\s\S]*>/i.test(value)) {
rich = true;
editor.dataset.rich = '1';
editor.innerHTML = sanitizeHtml(value);
} else {
rich = false;
editor.dataset.rich = '0';
editor.textContent = value;
}
syncDescription();
};
editor.addEventListener('paste', function(event) {
var data = event.clipboardData || window.clipboardData;
var html = data ? data.getData('text/html') : '';
if (!html) return;
event.preventDefault();
rich = true;
editor.dataset.rich = '1';
insertHtml(sanitizeHtml(html));
syncDescription();
});
editor.addEventListener('input', syncDescription);
var form = editor.closest('form');
if (form) form.addEventListener('submit', syncDescription);
syncDescription();
})();
</script>
<?php }
function render_pagination(int $current, int $total, string $url_pattern): void {
if ($total <= 1) return;
echo '<div class="pagination">';
$prev = $current - 1;
$next = $current + 1;
if ($prev >= 1) {
echo '<a href="' . sprintf($url_pattern, $prev) . '"></a>';
} else {
echo '<span class="disabled"></span>';
}
$range = range(max(1, $current-2), min($total, $current+2));
if (!in_array(1, $range)) { echo '<a href="' . sprintf($url_pattern, 1) . '">1</a><span>…</span>'; }
foreach ($range as $p) {
if ($p === $current) echo '<span class="current">' . $p . '</span>';
else echo '<a href="' . sprintf($url_pattern, $p) . '">' . $p . '</a>';
}
if (!in_array($total, $range)) { echo '<span>…</span><a href="' . sprintf($url_pattern, $total) . '">' . $total . '</a>'; }
if ($next <= $total) {
echo '<a href="' . sprintf($url_pattern, $next) . '"></a>';
} else {
echo '<span class="disabled"></span>';
}
echo '</div>';
}