- 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:
@@ -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">';
|
||||
|
||||
Reference in New Issue
Block a user