- 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.
This commit is contained in:
Ty Clifford
2026-07-01 15:41:01 -04:00
parent e9369e242c
commit 6a1c545f35
11 changed files with 787 additions and 17 deletions
+235
View File
@@ -228,6 +228,43 @@ function render_head(string $title = 'Media — TyClifford.com', string $extra_c
}
.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 {
@@ -290,6 +327,204 @@ function render_footer(): void { ?>
</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">';